mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
screenshots: PR #1918
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/nest_skill.md
|
||||
@@ -1,11 +0,0 @@
|
||||
[profile.dev]
|
||||
# Full file/line backtraces, no per-variable debug info. ~20-30% faster
|
||||
# builds and much faster macOS link. Need lldb locals? Override per-shell:
|
||||
# CARGO_PROFILE_DEV_DEBUG=2
|
||||
debug = "line-tables-only"
|
||||
|
||||
[env]
|
||||
# CMake 4 (pinned by hermit) removed compat with projects declaring
|
||||
# cmake_minimum_required < 3.5; audiopus_sys's vendored opus declares 3.1.
|
||||
# Same workaround CI uses. A value already set in the environment wins.
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/nest_skill.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/nest_skill.md
|
||||
@@ -1,54 +0,0 @@
|
||||
# Build outputs
|
||||
target/
|
||||
**/target/
|
||||
desktop/src-tauri/target/
|
||||
|
||||
# Native non-relay UI builds (not part of the public image)
|
||||
desktop/
|
||||
mobile/
|
||||
|
||||
# Node artifacts (built inside the image; host outputs would invalidate cache)
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
web/dist/
|
||||
|
||||
# VCS, IDE, scratch
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.vscode/
|
||||
.idea/
|
||||
.scratch/
|
||||
|
||||
# Local environment / secrets — never want these in the build context
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI artifacts and tooling that the image doesn't need
|
||||
.cache/
|
||||
coverage/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
bin/
|
||||
sidecars/
|
||||
|
||||
# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is
|
||||
# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files
|
||||
# inside crates (Cargo.toml `readme = ...`) likewise need to be present to
|
||||
# avoid manifest warnings; the savings from excluding them are negligible.
|
||||
docs/
|
||||
|
||||
# Old Dockerfile noise (this Dockerfile is THE source of truth now)
|
||||
docker-compose.yml
|
||||
docker-compose.*.yml
|
||||
prometheus.yml
|
||||
199
.env.example
199
.env.example
@@ -1,199 +0,0 @@
|
||||
# =============================================================================
|
||||
# Buzz Backend — Local Development Environment
|
||||
# =============================================================================
|
||||
# Copy this file to .env and adjust as needed:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# All defaults here work with `docker compose up` out of the box.
|
||||
#
|
||||
# Service ports (defaults):
|
||||
# Postgres → localhost:5432
|
||||
# Redis → localhost:6379
|
||||
# Typesense → localhost:8108
|
||||
# Adminer → localhost:8082 (DB browser UI)
|
||||
#
|
||||
# Note: If port 8082 conflicts, change the adminer port in docker-compose.yml
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (Postgres 17)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
|
||||
PGHOST=localhost
|
||||
PGPORT=5432
|
||||
PGUSER=buzz
|
||||
PGPASSWORD=buzz_dev
|
||||
PGDATABASE=buzz
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis 7
|
||||
# -----------------------------------------------------------------------------
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typesense (search)
|
||||
# -----------------------------------------------------------------------------
|
||||
TYPESENSE_API_KEY=buzz_dev_key
|
||||
TYPESENSE_URL=http://localhost:8108
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Relay (WebSocket server)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bind address for the relay (host:port)
|
||||
BUZZ_BIND_ADDR=0.0.0.0:3000
|
||||
# Public WebSocket URL — used in NIP-42 auth challenges
|
||||
RELAY_URL=ws://localhost:3000
|
||||
# Stable relay signing key. Set this in dev if you want REST-created forum posts
|
||||
# to keep resolving to the original author across relay restarts.
|
||||
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
|
||||
# Optional: path to the web UI dist directory. When set, the relay serves
|
||||
# the web frontend at / for browser requests. Leave unset for local dev
|
||||
# (use `just web` for Vite HMR instead).
|
||||
# BUZZ_WEB_DIR=./web/dist
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Git (NIP-34 bare repositories)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Root directory for bare git repos. Repos are stored at
|
||||
# {path}/{owner_hex}/{repo_id}.git/. Default: ./repos (relative to CWD).
|
||||
# Set an absolute path to keep repos stable across worktrees.
|
||||
# BUZZ_GIT_REPO_PATH=./repos
|
||||
# BUZZ_GIT_MAX_PACK_BYTES=524288000
|
||||
# BUZZ_GIT_MAX_REPO_BYTES=1048576000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media Upload Admission
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bound image/video parser and storage work admitted by one relay process.
|
||||
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
|
||||
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
|
||||
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Ephemeral Channels (TTL testing)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Override the TTL for all ephemeral channels (in seconds). When set, any
|
||||
# channel created with a TTL tag will use this value instead of the
|
||||
# client-provided one. Unset to use the client-provided TTL.
|
||||
# BUZZ_EPHEMERAL_TTL_OVERRIDE=60
|
||||
|
||||
# How often the reaper checks for expired ephemeral channels (default: 60s).
|
||||
# BUZZ_REAPER_INTERVAL_SECS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging / Tracing
|
||||
# -----------------------------------------------------------------------------
|
||||
RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug
|
||||
|
||||
# OTLP tracing endpoint (optional — leave unset to disable)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ACP (Agent Communication Protocol — buzz-acp harness)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The ACP harness bridges Buzz events to AI agents. Each env var below maps
|
||||
# to a CLI flag of the same name (lowercase, hyphens → underscores). All values
|
||||
# are optional unless noted; defaults are shown in comments.
|
||||
#
|
||||
# Quick start:
|
||||
# BUZZ_PRIVATE_KEY=<hex> BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp
|
||||
|
||||
# ── Identity & auth ──────────────────────────────────────────────────────────
|
||||
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
|
||||
# BUZZ_PRIVATE_KEY=<32-byte hex or nsec1… private key>
|
||||
|
||||
# Relay WebSocket URL the harness connects to.
|
||||
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
|
||||
# connection target — they happen to point at the same place in local dev.
|
||||
# BUZZ_RELAY_URL=ws://localhost:3000
|
||||
|
||||
# ── Agent subprocess ─────────────────────────────────────────────────────────
|
||||
# Binary to spawn as the AI agent (e.g. "goose", "codex-acp", "claude-code").
|
||||
# BUZZ_ACP_AGENT_COMMAND=goose
|
||||
|
||||
# Comma-separated arguments passed to the agent binary.
|
||||
# Goose default: "acp". Codex/Claude default: "" (empty).
|
||||
# BUZZ_ACP_AGENT_ARGS=acp
|
||||
|
||||
# Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent).
|
||||
# BUZZ_ACP_MCP_COMMAND=
|
||||
|
||||
# Number of parallel agent subprocesses (1–32).
|
||||
# BUZZ_ACP_AGENTS=1
|
||||
|
||||
# Desired LLM model ID. Applied to every new ACP session.
|
||||
# Use `buzz-acp models` to discover available model IDs.
|
||||
# BUZZ_ACP_MODEL=
|
||||
|
||||
# ── Timeouts & sessions ──────────────────────────────────────────────────────
|
||||
# Max seconds per agent turn before timeout (default 320 = ~5 min).
|
||||
# BUZZ_ACP_TURN_TIMEOUT=320
|
||||
|
||||
# Max turns per session before proactive rotation. 0 = disabled (rotate only
|
||||
# on MaxTokens / MaxTurnRequests). Recommended: 50 for long-running agents.
|
||||
# BUZZ_ACP_MAX_TURNS_PER_SESSION=0
|
||||
|
||||
# ── Prompts ──────────────────────────────────────────────────────────────────
|
||||
# System prompt injected into every agent session (inline text).
|
||||
# BUZZ_ACP_SYSTEM_PROMPT=
|
||||
|
||||
# Path to a file containing the system prompt (mutually exclusive with above).
|
||||
# BUZZ_ACP_SYSTEM_PROMPT_FILE=
|
||||
|
||||
# Message sent to the agent immediately after session creation.
|
||||
# BUZZ_ACP_INITIAL_MESSAGE=
|
||||
|
||||
# ── Heartbeat ────────────────────────────────────────────────────────────────
|
||||
# Seconds between heartbeat prompts. 0 = disabled. Must be 0 or ≥10.
|
||||
# Recommended: 60 for long-running agents to prevent idle session timeouts.
|
||||
# BUZZ_ACP_HEARTBEAT_INTERVAL=0
|
||||
|
||||
# Heartbeat prompt text (inline). Mutually exclusive with file variant.
|
||||
# BUZZ_ACP_HEARTBEAT_PROMPT=
|
||||
|
||||
# Path to a file containing the heartbeat prompt.
|
||||
# BUZZ_ACP_HEARTBEAT_PROMPT_FILE=
|
||||
|
||||
# ── Subscription & filtering ─────────────────────────────────────────────────
|
||||
# Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
|
||||
# BUZZ_ACP_SUBSCRIBE=mentions
|
||||
|
||||
# Comma-separated event kind numbers to subscribe to (overrides mode defaults).
|
||||
# BUZZ_ACP_KINDS=
|
||||
|
||||
# Comma-separated channel UUIDs to limit subscription scope.
|
||||
# BUZZ_ACP_CHANNELS=
|
||||
|
||||
# Set to true to disable the @-mention filter in mentions mode.
|
||||
# BUZZ_ACP_NO_MENTION_FILTER=false
|
||||
|
||||
# Path to TOML config file for rule-based subscriptions (config mode).
|
||||
# BUZZ_ACP_CONFIG=./buzz-acp.toml
|
||||
|
||||
# ── Dedup & self-ignore ──────────────────────────────────────────────────────
|
||||
# How to handle duplicate events: "queue" (default) or "drop".
|
||||
# BUZZ_ACP_DEDUP=queue
|
||||
|
||||
# Set to true to process the agent's own messages (default: ignore self).
|
||||
# BUZZ_ACP_NO_IGNORE_SELF=false
|
||||
|
||||
# ── Context ──────────────────────────────────────────────────────────────────
|
||||
# Max context messages fetched for thread replies and DMs (0–100). 0 = disabled.
|
||||
# BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12
|
||||
|
||||
# ── Presence & typing ────────────────────────────────────────────────────────
|
||||
# Set to true to disable automatic online/offline presence status.
|
||||
# BUZZ_ACP_NO_PRESENCE=false
|
||||
|
||||
# Set to true to disable typing indicators while the agent is processing.
|
||||
# BUZZ_ACP_NO_TYPING=false
|
||||
|
||||
# ── Advanced tuning ──────────────────────────────────────────────────────────
|
||||
# Event channel buffer capacity (WebSocket → harness). Increase for
|
||||
# high-throughput agents. Minimum 1.
|
||||
# BUZZ_ACP_EVENT_BUFFER=256
|
||||
|
||||
# ── Legacy aliases ───────────────────────────────────────────────────────────
|
||||
# These are accepted for backward compatibility but the canonical names above
|
||||
# are preferred:
|
||||
# BUZZ_ACP_PRIVATE_KEY → BUZZ_PRIVATE_KEY
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
||||
* @block/buzz-oss-team
|
||||
31
.github/ISSUE_TEMPLATE/bug-report.md
vendored
31
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Thank you for taking the time, please report a reproducible bug
|
||||
title: "[Bug] <Bug Title Here>"
|
||||
labels: bug
|
||||
assignees: add codeowner's @name here
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
*A clear and concise description of what the bug is.*
|
||||
|
||||
**To Reproduce:**
|
||||
*Steps to reproduce the behavior:*
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior:**
|
||||
*A clear and concise description of what you expected to happen.*
|
||||
|
||||
**Supporting Material**
|
||||
*If applicable, add screenshots, output log and/or other documentation to help explain your problem.*
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [ex: iOS]
|
||||
- Version
|
||||
|
||||
**Additional context**
|
||||
Add any other context that you feel is relevant about the problem here.
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +0,0 @@
|
||||
contact_links:
|
||||
- name: ❓ Questions and Help 🤔
|
||||
url: https://discord.gg/block-opensource (/add your discord channel if applicable)
|
||||
about: This issue tracker is not for support questions. Please refer to the community for more help.
|
||||
149
.github/workflows/auto-tag-on-release-pr-merge.yml
vendored
149
.github/workflows/auto-tag-on-release-pr-merge.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: Auto-tag on Release PR Merge
|
||||
|
||||
# Five release lanes share this one workflow. Four use an explicit branch
|
||||
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
|
||||
# a chart feature PR can publish its own new version when merged:
|
||||
#
|
||||
# version-bump/<v> → tag v<v> → dispatch release.yml (desktop app)
|
||||
# relay-release/<v> → tag relay-v<v> → dispatch docker.yml (relay image)
|
||||
# chart-release/<v> → tag chart-v<v> → dispatch helm-chart.yml (main helm chart)
|
||||
# push-chart-release/<v> → tag push-chart-v<v> → dispatch push-gateway-helm-chart.yml
|
||||
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
|
||||
# → tag chart-v<v> → dispatch helm-chart.yml (helm chart)
|
||||
# mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
|
||||
#
|
||||
# The desktop, relay, and chart lanes dispatch their build workflow rather than
|
||||
# relying on the consumer's `on.push.tags` trigger: auto-tag pushes the tag
|
||||
# with the default GITHUB_TOKEN, which GitHub's recursion guard blocks from
|
||||
# firing any `on: push` trigger. So release.yml, docker.yml, and helm-chart.yml
|
||||
# all have a `push.tags` trigger that is dead for auto-pushed tags — the
|
||||
# dispatch is the real path. Each dispatch passes the bare version and the tag
|
||||
# ref so the consumer builds the tagged commit (github.ref on a dispatch is
|
||||
# `main`).
|
||||
#
|
||||
# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must
|
||||
# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across
|
||||
# that boundary is deliberately disallowed. The mobile-v* tag is consumed
|
||||
# manually instead — a human feeds it as the `sprout_ref` input to the
|
||||
# `buzz-releases` Buildkite pipeline, which builds and ships mobile.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
auto-tag:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release lane and version
|
||||
id: release
|
||||
env:
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
# Explicit release branches retain their established behavior. For an
|
||||
# ordinary internal PR, publish only when Chart.yaml itself changed
|
||||
# and its version differs from the PR's base commit.
|
||||
case "$BRANCH" in
|
||||
version-bump/*)
|
||||
VERSION="${BRANCH#version-bump/}"
|
||||
TAG_PREFIX="v"
|
||||
DISPATCH="release" ;;
|
||||
relay-release/*)
|
||||
VERSION="${BRANCH#relay-release/}"
|
||||
TAG_PREFIX="relay-v"
|
||||
DISPATCH="docker" ;;
|
||||
chart-release/*)
|
||||
VERSION="${BRANCH#chart-release/}"
|
||||
TAG_PREFIX="chart-v"
|
||||
DISPATCH="helm-chart" ;;
|
||||
push-chart-release/*)
|
||||
VERSION="${BRANCH#push-chart-release/}"
|
||||
TAG_PREFIX="push-chart-v"
|
||||
DISPATCH="push-gateway-helm-chart" ;;
|
||||
mobile-release/*)
|
||||
VERSION="${BRANCH#mobile-release/}"
|
||||
TAG_PREFIX="mobile-v"
|
||||
DISPATCH="" ;;
|
||||
*)
|
||||
parent_sha="$(git rev-parse HEAD^)"
|
||||
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
|
||||
VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)"
|
||||
if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then
|
||||
echo "No release branch or chart version bump — nothing to tag"
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
TAG_PREFIX="chart-v"
|
||||
DISPATCH="helm-chart" ;;
|
||||
esac
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "::error::Invalid release version: '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "enabled=true"
|
||||
echo "version=$VERSION"
|
||||
echo "tag=${TAG_PREFIX}${VERSION}"
|
||||
echo "dispatch=$DISPATCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Tagging ${TAG_PREFIX}${VERSION}"
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.release.outputs.enabled == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')"
|
||||
if [ -n "$EXISTING_SHA" ]; then
|
||||
if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then
|
||||
echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation"
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Trigger release build
|
||||
# The desktop, relay, and chart lanes dispatch their build workflow
|
||||
# because the consumer's on:push:tags trigger is dead for tags pushed
|
||||
# by GITHUB_TOKEN (recursion guard — see header comment). Mobile has no
|
||||
# consumer yet, so dispatch="" skips this step entirely.
|
||||
if: steps.release.outputs.enabled == 'true' && steps.release.outputs.dispatch != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DISPATCH: ${{ steps.release.outputs.dispatch }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
# Both workflows take the bare version (for the build) and the tag ref
|
||||
# (so the dispatch builds the tagged commit, not main).
|
||||
case "$DISPATCH" in
|
||||
release) WORKFLOW="release.yml" ;;
|
||||
docker) WORKFLOW="docker.yml" ;;
|
||||
helm-chart) WORKFLOW="helm-chart.yml" ;;
|
||||
push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml" ;;
|
||||
*)
|
||||
echo "::error::Unhandled dispatch target: '$DISPATCH'"
|
||||
exit 1 ;;
|
||||
esac
|
||||
gh workflow run "$WORKFLOW" \
|
||||
-f version="$VERSION" \
|
||||
-f ref="$TAG"
|
||||
39
.github/workflows/benchmark-harbor.yml
vendored
39
.github/workflows/benchmark-harbor.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Harbor Buzz Orchestra
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "benchmarks/harbor-buzz-orchestra/**"
|
||||
- ".github/workflows/benchmark-harbor.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "benchmarks/harbor-buzz-orchestra/**"
|
||||
- ".github/workflows/benchmark-harbor.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Python tests and lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Test adapter
|
||||
working-directory: benchmarks/harbor-buzz-orchestra
|
||||
run: |
|
||||
python -m pip install --disable-pip-version-check -e ".[dev]"
|
||||
pytest -q
|
||||
ruff check .
|
||||
- name: Test provisioner
|
||||
working-directory: benchmarks/harbor-buzz-orchestra/testbed
|
||||
run: |
|
||||
python -m pip install --disable-pip-version-check -e ".[dev]"
|
||||
pytest -q
|
||||
ruff check .
|
||||
974
.github/workflows/ci.yml
vendored
974
.github/workflows/ci.yml
vendored
@@ -1,974 +0,0 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main, release]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect Changed Paths
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
rust: ${{ steps.filter.outputs.rust }}
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
desktop-rust: ${{ steps.filter.outputs.desktop-rust }}
|
||||
web: ${{ steps.filter.outputs.web }}
|
||||
mobile: ${{ steps.filter.outputs.mobile }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- 'crates/**'
|
||||
- 'migrations/**'
|
||||
- 'schema/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'deny.toml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'scripts/run-tests.sh'
|
||||
- 'justfile'
|
||||
desktop:
|
||||
- 'desktop/**'
|
||||
- '!desktop/src-tauri/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
desktop-rust:
|
||||
- 'desktop/src-tauri/**'
|
||||
web:
|
||||
- 'web/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
rust-lint:
|
||||
name: Rust Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Format check
|
||||
run: just fmt-check
|
||||
- name: Desktop Tauri format check
|
||||
run: just desktop-tauri-fmt-check
|
||||
- name: Clippy
|
||||
run: just clippy
|
||||
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Install cargo-nextest
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cargo-nextest@0.9.136
|
||||
- name: Unit tests
|
||||
run: just test-unit
|
||||
|
||||
desktop-core:
|
||||
name: Desktop Core
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: desktop/src-tauri
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Install Tauri dependencies (Linux)
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
sudo apt-get update \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=120 \
|
||||
build-essential \
|
||||
curl \
|
||||
file \
|
||||
libasound2-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
patchelf \
|
||||
wget
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
- name: Desktop lint and format
|
||||
run: just desktop-check
|
||||
- name: Desktop unit tests
|
||||
run: just desktop-test
|
||||
- name: Desktop build
|
||||
run: just desktop-build
|
||||
- name: Desktop Tauri clippy
|
||||
run: just desktop-tauri-clippy
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Desktop Tauri check
|
||||
run: just desktop-tauri-check
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Desktop Tauri tests
|
||||
run: just desktop-tauri-test
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Upload desktop e2e artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: desktop-e2e-artifacts
|
||||
path: |
|
||||
desktop/playwright-report
|
||||
desktop/test-results
|
||||
if-no-files-found: ignore
|
||||
- name: Save pnpm store cache
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
desktop-smoke-e2e:
|
||||
name: Desktop Smoke E2E (${{ matrix.shard }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
- name: Get Playwright version
|
||||
id: pw-version
|
||||
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore Playwright browser cache
|
||||
id: playwright-cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: cd desktop && pnpm exec playwright install chromium
|
||||
- name: Install Playwright system dependencies
|
||||
run: cd desktop && pnpm exec playwright install-deps chromium
|
||||
- name: Save Playwright browser cache
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
- name: Desktop build
|
||||
run: just desktop-build
|
||||
- name: Desktop smoke e2e
|
||||
run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4
|
||||
- name: Summarize flaky tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})"
|
||||
working-directory: desktop
|
||||
- name: Upload desktop smoke e2e artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: desktop-smoke-e2e-artifacts-${{ matrix.shard }}
|
||||
path: |
|
||||
desktop/playwright-report
|
||||
desktop/playwright-report.json
|
||||
desktop/test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
desktop:
|
||||
name: Desktop
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: [changes, desktop-core, desktop-smoke-e2e]
|
||||
if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check desktop jobs
|
||||
run: |
|
||||
if [ "${{ needs.desktop-core.result }}" != "success" ]; then
|
||||
echo "Desktop Core finished with: ${{ needs.desktop-core.result }}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then
|
||||
echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Desktop jobs passed"
|
||||
|
||||
desktop-e2e-relay:
|
||||
name: Desktop E2E Relay
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: |
|
||||
.
|
||||
desktop/src-tauri
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Build relay
|
||||
run: cargo build --profile ci -p buzz-relay
|
||||
- name: Upload relay binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: desktop-e2e-relay
|
||||
path: target/ci/buzz-relay
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
desktop-e2e-integration-shard:
|
||||
name: Desktop E2E Integration (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [changes, desktop-e2e-relay]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Start integration services
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose up -d postgres redis minio minio-init; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "docker compose up failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
- name: Get Playwright version
|
||||
id: pw-version
|
||||
run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore Playwright browser cache
|
||||
id: playwright-cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: cd desktop && pnpm exec playwright install chromium
|
||||
- name: Install Playwright system dependencies
|
||||
run: cd desktop && pnpm exec playwright install-deps chromium
|
||||
- name: Save Playwright browser cache
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
- name: Desktop build
|
||||
run: just desktop-build
|
||||
- name: Wait for integration services
|
||||
run: |
|
||||
wait_healthy() {
|
||||
local service="$1"
|
||||
local container="$2"
|
||||
for attempt in $(seq 1 60); do
|
||||
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
|
||||
if [ "${status}" = "healthy" ]; then
|
||||
echo "${service} is healthy"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs "${container}" || true
|
||||
return 1
|
||||
}
|
||||
wait_healthy "Postgres" "buzz-postgres"
|
||||
wait_healthy "Redis" "buzz-redis"
|
||||
wait_healthy "MinIO" "buzz-minio"
|
||||
- name: Download relay binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: desktop-e2e-relay
|
||||
path: target/ci
|
||||
- name: Apply schema and seed deployment community
|
||||
# MT: the relay resolves each request's tenant from the communities host
|
||||
# map and fails closed on an unmapped host. The channel reconciler binds
|
||||
# the deployment community ONCE at boot (outside its retry loop) and
|
||||
# exits permanently on an unmapped host, so the 'localhost:3000'
|
||||
# community MUST exist before the relay starts — the retry loop only
|
||||
# handles late-seeded channels, not a late-seeded community. The relay
|
||||
# migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the
|
||||
# pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE
|
||||
# below). lower(host) is the unique index → ON CONFLICT target. psql
|
||||
# isn't on PATH in hermit → exec into the buzz-postgres container.
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPORT: "5432"
|
||||
PGUSER: buzz
|
||||
PGPASSWORD: buzz_dev
|
||||
PGDATABASE: buzz
|
||||
# Use the already-running docker postgres for desired-state planning instead of
|
||||
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
|
||||
PGSCHEMA_PLAN_HOST: localhost
|
||||
PGSCHEMA_PLAN_PORT: "5432"
|
||||
PGSCHEMA_PLAN_DB: buzz
|
||||
PGSCHEMA_PLAN_USER: buzz
|
||||
PGSCHEMA_PLAN_PASSWORD: buzz_dev
|
||||
run: |
|
||||
./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
|
||||
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
|
||||
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
|
||||
psql -U buzz -d buzz -qtA -c "
|
||||
INSERT INTO communities (id, host)
|
||||
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
|
||||
ON CONFLICT (lower(host)) DO NOTHING
|
||||
;"
|
||||
- name: Start relay
|
||||
run: |
|
||||
chmod +x ./target/ci/buzz-relay
|
||||
nohup env \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
RELAY_URL=ws://localhost:3000 \
|
||||
BUZZ_BIND_ADDR=0.0.0.0:3000 \
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
BUZZ_GIT_PROBE_WRITERS=8 \
|
||||
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
|
||||
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
|
||||
echo $! > /tmp/buzz-relay.pid
|
||||
for attempt in $(seq 1 60); do
|
||||
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
fi
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
|
||||
if [ "${status_code}" = "200" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
- name: Seed desktop e2e data
|
||||
run: bash scripts/setup-desktop-test-data.sh
|
||||
- name: Desktop relay-backed e2e
|
||||
run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2
|
||||
- name: Summarize flaky tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)"
|
||||
working-directory: desktop
|
||||
- name: Upload desktop integration artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: desktop-e2e-integration-artifacts-${{ matrix.shard }}
|
||||
path: |
|
||||
desktop/playwright-report
|
||||
desktop/playwright-report.json
|
||||
desktop/test-results
|
||||
/tmp/buzz-relay.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Save pnpm store cache
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
desktop-e2e-integration:
|
||||
name: Desktop E2E Integration
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: [changes, desktop-e2e-integration-shard]
|
||||
if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check integration shards
|
||||
run: |
|
||||
if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then
|
||||
echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Desktop E2E Integration shards passed"
|
||||
|
||||
backend-integration:
|
||||
name: Backend Integration (relay e2e)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [changes, desktop-e2e-relay]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Start integration services
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose up -d postgres redis minio minio-init; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "docker compose up failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
- name: Wait for integration services
|
||||
run: |
|
||||
wait_healthy() {
|
||||
local service="$1"
|
||||
local container="$2"
|
||||
for attempt in $(seq 1 60); do
|
||||
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
|
||||
if [ "${status}" = "healthy" ]; then
|
||||
echo "${service} is healthy"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs "${container}" || true
|
||||
return 1
|
||||
}
|
||||
wait_healthy "Postgres" "buzz-postgres"
|
||||
wait_healthy "Redis" "buzz-redis"
|
||||
wait_healthy "MinIO" "buzz-minio"
|
||||
- name: Download relay binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: desktop-e2e-relay
|
||||
path: target/ci
|
||||
- name: Apply schema and seed deployment community
|
||||
# MT: the relay resolves each request's tenant from the communities host
|
||||
# map and fails closed on an unmapped host. The reminder scheduler binds
|
||||
# the deployment community ONCE at boot and exits permanently on an
|
||||
# unmapped host (no retry, unlike the channel reconciler), so the
|
||||
# 'localhost:3000' community MUST exist before the relay starts — seeding
|
||||
# after boot leaves the scheduler dead. The relay migrates at boot via
|
||||
# BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply
|
||||
# the schema here first (then drop AUTO_MIGRATE below). lower(host) is the
|
||||
# unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec
|
||||
# into the buzz-postgres container.
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPORT: "5432"
|
||||
PGUSER: buzz
|
||||
PGPASSWORD: buzz_dev
|
||||
PGDATABASE: buzz
|
||||
# Use the already-running docker postgres for desired-state planning instead of
|
||||
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
|
||||
PGSCHEMA_PLAN_HOST: localhost
|
||||
PGSCHEMA_PLAN_PORT: "5432"
|
||||
PGSCHEMA_PLAN_DB: buzz
|
||||
PGSCHEMA_PLAN_USER: buzz
|
||||
PGSCHEMA_PLAN_PASSWORD: buzz_dev
|
||||
run: |
|
||||
./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
|
||||
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
|
||||
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
|
||||
psql -U buzz -d buzz -qtA -c "
|
||||
INSERT INTO communities (id, host)
|
||||
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
|
||||
ON CONFLICT (lower(host)) DO NOTHING
|
||||
;"
|
||||
- name: Start relay
|
||||
run: |
|
||||
chmod +x ./target/ci/buzz-relay
|
||||
nohup env \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
RELAY_URL=ws://localhost:3000 \
|
||||
BUZZ_BIND_ADDR=0.0.0.0:3000 \
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
BUZZ_GIT_PROBE_WRITERS=8 \
|
||||
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
|
||||
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
|
||||
echo $! > /tmp/buzz-relay.pid
|
||||
for attempt in $(seq 1 60); do
|
||||
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
fi
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
|
||||
if [ "${status_code}" = "200" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
- name: NIP-ER reminder e2e
|
||||
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
|
||||
# validation, author-only read filtering, and scheduler delivery against
|
||||
# a live relay. The schema-drift / migration-version guarantee is owned
|
||||
# by the buzz-db migration.rs unit tests, not this suite.
|
||||
run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored
|
||||
env:
|
||||
RELAY_URL: ws://localhost:3000
|
||||
- name: Upload relay log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: backend-integration-relay-log
|
||||
path: /tmp/buzz-relay.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
relay-e2e:
|
||||
name: Relay E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Start relay
|
||||
run: ./scripts/start-relay-for-tests.sh
|
||||
- name: Relay E2E tests
|
||||
run: cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
|
||||
env:
|
||||
RELAY_URL: ws://localhost:3000
|
||||
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
|
||||
- name: Upload relay logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: relay-e2e-artifacts
|
||||
path: /tmp/buzz-relay.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
web:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.web == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Web lint and format
|
||||
run: just web-check
|
||||
- name: Web build
|
||||
run: just web-build
|
||||
- name: Save pnpm store cache
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
mobile:
|
||||
name: Mobile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Compute Hermit cache key
|
||||
id: hermit-bin-hash
|
||||
run: |
|
||||
hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)"
|
||||
echo "hash=$hash" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore Hermit package cache
|
||||
id: hermit-cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ~/.cache/hermit/pkg
|
||||
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
|
||||
restore-keys: ${{ runner.os }}-hermit-cache-
|
||||
- name: Prime Flutter SDK
|
||||
run: flutter --version
|
||||
- name: Save Hermit package cache
|
||||
if: always() && steps.hermit-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.cache/hermit/pkg
|
||||
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
|
||||
- name: Restore pub cache
|
||||
id: pub-cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
- name: Install dependencies
|
||||
run: cd mobile && flutter pub get
|
||||
- name: Save pub cache
|
||||
if: always() && steps.pub-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
|
||||
- name: Format check
|
||||
run: cd mobile && dart format --output=none --set-exit-if-changed .
|
||||
- name: Analyze
|
||||
run: cd mobile && flutter analyze
|
||||
- name: Test
|
||||
run: cd mobile && flutter test
|
||||
- name: Build Android debug APK
|
||||
run: just mobile-build-android
|
||||
|
||||
security:
|
||||
name: Security
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Dependency policy
|
||||
run: cargo-deny check
|
||||
|
||||
dead-token-guard:
|
||||
name: Dead Token Reference Guard
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- name: Check for dead API token references in client code
|
||||
run: |
|
||||
# Fail if dead API token patterns reappear in desktop, mobile, docs, or config.
|
||||
# Relay crates are excluded — they still use token auth internally.
|
||||
PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_'
|
||||
PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example'
|
||||
EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool'
|
||||
if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then
|
||||
echo "::error::Dead API token references found in client code. See above."
|
||||
exit 1
|
||||
fi
|
||||
echo "No dead token references found."
|
||||
|
||||
server-cross-compile:
|
||||
name: Server Cross-Compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-unknown-linux-musl
|
||||
- aarch64-unknown-linux-musl
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
key: cross-${{ matrix.target }}
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Install cross
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cross@0.2.5
|
||||
- name: Build server binaries
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
# PRs: compile + build-script gate only (no codegen/link). Main: full link gate.
|
||||
CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }}
|
||||
run: |
|
||||
cross "$CARGO_CMD" --release --target "$TARGET" \
|
||||
-p buzz-relay \
|
||||
-p buzz-acp \
|
||||
-p buzz-agent \
|
||||
-p buzz-dev-mcp \
|
||||
-p git-credential-nostr \
|
||||
-p git-sign-nostr
|
||||
|
||||
windows-rust:
|
||||
name: Windows Rust (x86_64-pc-windows-msvc)
|
||||
runs-on: windows-latest
|
||||
# Windows runners are slow and this compiles the workspace + Tauri crate
|
||||
# cold across four steps; budget generously.
|
||||
timeout-minutes: 45
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
TARGET: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
# MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows
|
||||
# runner — hermit, used by the Linux jobs, does not provide MSVC. The
|
||||
# toolchain (1.95.0 + clippy via profile = default) comes from the
|
||||
# repo-root rust-toolchain.toml, which the runner's preinstalled rustup
|
||||
# honors on demand; the host triple already is x86_64-pc-windows-msvc.
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: |
|
||||
.
|
||||
desktop/src-tauri
|
||||
key: windows-msvc
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
# Tauri validates externalBin at compile time, so the Tauri-crate steps
|
||||
# below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's
|
||||
# Windows naming (binaries/<bin>-<triple>.exe); empty files suffice for a
|
||||
# type-check since nothing executes them.
|
||||
- name: Create sidecar placeholders
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
|
||||
touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe"
|
||||
done
|
||||
- name: Clippy (workspace)
|
||||
run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings
|
||||
- name: Check (workspace)
|
||||
run: cargo check --workspace --all-targets --target $env:TARGET
|
||||
- name: Test (buzz-dev-mcp)
|
||||
# The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests
|
||||
# only gate if this crate is tested ON Windows.
|
||||
# Serial: windows_resolver_tests mutate process-global env
|
||||
# (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads.
|
||||
run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1
|
||||
# Smoke-test the new host-prereq contract: Git for Windows (which provides
|
||||
# bash) is available on the runner, a shell command round-trips, and bash
|
||||
# does NOT resolve from System32 (so WSL's launcher is never picked up).
|
||||
# windows-latest runners have Git for Windows pre-installed; the unit tests
|
||||
# above exercise the MCP resolver itself. This step verifies the host env.
|
||||
- name: Smoke-test host Git Bash prereq (host env check)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Git for Windows ships bash.exe under its bin/ directory; confirm it
|
||||
# resolves from the standard location the runtime resolver probes first.
|
||||
bash_path=$(command -v bash 2>/dev/null || true)
|
||||
[[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; }
|
||||
echo "Resolved bash: $bash_path"
|
||||
[[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; }
|
||||
|
||||
# Run a basic pipeline through the resolved bash (same invocation the
|
||||
# agent uses: bash -c '...').
|
||||
out=$(bash -c 'echo hello | tr a-z A-Z')
|
||||
[[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; }
|
||||
|
||||
# Confirm git itself works — agents run git commands frequently.
|
||||
git --version
|
||||
repo=$(mktemp -d)
|
||||
cd "$repo"
|
||||
git init -q
|
||||
git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke
|
||||
git log -1 --format=%s | grep -qx smoke
|
||||
echo "Host bash resolved and functional; git commit round-trip passed"
|
||||
- name: Check (Tauri crate)
|
||||
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
- name: Test (Tauri crate)
|
||||
run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
|
||||
desktop-build-macos:
|
||||
name: Desktop Build (macOS)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
needs: [changes]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: desktop/src-tauri
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
- name: Create sidecar placeholders
|
||||
run: |
|
||||
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-$TARGET"
|
||||
# Mesh rev is derived from Cargo.lock so a dependency bump needs no
|
||||
# lockstep edit here; the cache key tracks it automatically.
|
||||
- name: Resolve mesh-llm rev
|
||||
id: mesh_rev
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
|
||||
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore mesh llama build cache
|
||||
id: llama_cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build mesh llama native libraries
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||
SHORT="$MESH_REV_SHORT"
|
||||
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
|
||||
if [[ -z "$MESH_ROOT" ]]; then
|
||||
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
|
||||
exit 1
|
||||
fi
|
||||
export LLAMA_STAGE_BACKEND=metal
|
||||
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
- name: Save mesh llama build cache
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build Tauri app
|
||||
run: cd desktop && pnpm tauri build
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||
LLAMA_STAGE_BACKEND: metal
|
||||
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||
454
.github/workflows/docker.yml
vendored
454
.github/workflows/docker.yml
vendored
@@ -1,454 +0,0 @@
|
||||
name: Docker image
|
||||
|
||||
# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz.
|
||||
#
|
||||
# Strategy: each architecture builds on its native runner (ubuntu-24.04 for
|
||||
# amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final
|
||||
# job stitches the per-arch digests into a single multi-arch manifest.
|
||||
# This avoids QEMU emulation (~10× slower for Rust) at zero cost on free
|
||||
# GitHub-hosted runners.
|
||||
#
|
||||
# Versioning: the relay is versioned independently of the desktop app via
|
||||
# its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and
|
||||
# agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so
|
||||
# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop.
|
||||
#
|
||||
# Triggers:
|
||||
# - push to main → :main + :sha-<7>
|
||||
# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major}
|
||||
# (+ :latest for stable, NOT for prereleases)
|
||||
# - pull_request → build only (no push), cache stays warm
|
||||
# - workflow_dispatch → manual canary (no inputs), or relay-tag rescue
|
||||
# dispatch with version+ref inputs (see below)
|
||||
#
|
||||
# Why workflow_dispatch carries version/ref inputs:
|
||||
# auto-tag-on-release-pr-merge.yml pushes relay-v* with the default
|
||||
# GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers
|
||||
# (recursion guard). So the push:tags trigger above never runs for releases.
|
||||
# auto-tag instead dispatches this workflow with the bare version + tag ref,
|
||||
# the same rescue release.yml already uses for the desktop lane. On dispatch
|
||||
# github.ref is `main`, so the tag ref is plumbed through explicitly: checkout
|
||||
# pins to inputs.ref, and the semver tags take inputs.version via `value=`.
|
||||
# On the rescue path inputs.version is already bare (e.g. 0.3.0), so the
|
||||
# match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value
|
||||
# intact) and the bare version flows straight to the semver parser. On a
|
||||
# real push event value= is empty and the match strips relay-v from the ref.
|
||||
# Inputless canary dispatch (version="", ref=main) renders no semver tag.
|
||||
#
|
||||
# The :latest tag tracks the latest STABLE relay release: metadata-action's
|
||||
# `flavor.latest=auto` (its default) emits :latest only for non-prerelease
|
||||
# semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest,
|
||||
# and main pushes (no semver tag) never produce :latest.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["relay-v[0-9]*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.push-gateway"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "crates/**"
|
||||
- "web/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "patches/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch"
|
||||
required: false
|
||||
ref:
|
||||
description: "Tag/branch/SHA to build, e.g. relay-v0.3.0"
|
||||
required: false
|
||||
default: main
|
||||
|
||||
# One image build per ref; cancel superseded PR builds, but never cancel
|
||||
# tag/main builds (publishing must not be aborted mid-flight).
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
# Single source of truth for the image name. Set GHCR_IMAGE as a repo
|
||||
# variable to override (e.g., for forks that want to push to their own
|
||||
# namespace without forking this file).
|
||||
IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # push to GHCR
|
||||
id-token: write # OIDC for build provenance attestation
|
||||
attestations: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
|
||||
outputs:
|
||||
# Used downstream by `merge` to stitch the manifest.
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
# On workflow_dispatch (relay-tag rescue) build the tagged commit,
|
||||
# not main. Empty string = default ref for push/PR events.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
with:
|
||||
# Default parallelism of 4 OOMs the 7GB GitHub runner during Rust
|
||||
# compiles (see moby/buildkit#3969). Vaultwarden hit this; we will
|
||||
# too without the cap.
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 2
|
||||
|
||||
- name: Log in to GHCR
|
||||
# Skip on pull_request from forks — no GHCR creds, build-only.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
# Tag matrix — every main commit gets sha-<7>, relay releases get the
|
||||
# full semver family. The semver entries carry match=^relay-v(.*)$
|
||||
# because metadata-action does NOT strip a `relay-v` prefix on its
|
||||
# own — it only strips refs/tags/, then runs the raw ref through
|
||||
# semver.valid(), which rejects "relay-v0.3.0". The match capture
|
||||
# group feeds the bare version to the semver parser. value= supplies
|
||||
# the version on the auto-tag rescue dispatch (github.ref is `main`
|
||||
# there, not the tag): it is already bare, so match no-ops (warns,
|
||||
# value intact) and the bare version validates as-is. On push value=
|
||||
# is empty, so the ref drives it and match strips relay-v — push
|
||||
# behavior is unchanged. Pull requests get nothing (push: false
|
||||
# below). :latest is intentionally absent — flavor.latest defaults to
|
||||
# `auto`, which adds :latest for stable semver tags only (not
|
||||
# prereleases, not main pushes).
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Buzz
|
||||
org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Push by digest, not by tag — the merge job assembles the tags
|
||||
# into one multi-arch manifest. This is what makes the native-arm
|
||||
# matrix possible.
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
|
||||
cache-to: |
|
||||
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge multi-arch manifest
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # push the merged manifest
|
||||
id-token: write # OIDC for provenance attestation on the manifest
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Download all per-arch digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
# Must mirror the build job's tag matrix exactly — the merge job
|
||||
# re-derives tags to stamp them onto the multi-arch manifest. See
|
||||
# the build job's `meta` step for why match=^relay-v(.*)$, why
|
||||
# value=${{ inputs.version }} carries the rescue-dispatch version,
|
||||
# and why :latest is left to flavor.latest=auto.
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
id: manifest
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Build -t flags from the metadata-action output.
|
||||
tags=()
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] && tags+=("-t" "$tag")
|
||||
done <<< "$META_TAGS"
|
||||
|
||||
# Build the digest refs from the per-arch artifacts.
|
||||
digests=()
|
||||
for digest in *; do
|
||||
digests+=("${IMAGE_NAME}@sha256:${digest}")
|
||||
done
|
||||
|
||||
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
|
||||
|
||||
# Capture the merged manifest digest for the attestation step.
|
||||
first_tag=$(echo "$META_TAGS" | head -n1)
|
||||
merged_digest=$(docker buildx imagetools inspect "$first_tag" \
|
||||
--format '{{json .Manifest}}' | jq -r '.digest')
|
||||
echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attest provenance for the merged image
|
||||
# Sigstore-signed in-toto attestation, verifiable with:
|
||||
# gh attestation verify oci://ghcr.io/block/buzz:<tag> --owner block
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
{
|
||||
echo "### Published \`${IMAGE_NAME}\`"
|
||||
echo
|
||||
echo "**Digest:** \`${MERGED_DIGEST}\`"
|
||||
echo
|
||||
echo "**Tags:**"
|
||||
echo '```'
|
||||
echo "${META_TAGS}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Verify provenance:"
|
||||
echo '```'
|
||||
echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
push-gateway-build:
|
||||
name: Build public push gateway (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
persist-credentials: false
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 2
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/block/buzz-push-gateway
|
||||
labels: |
|
||||
org.opencontainers.image.title=Buzz Push Gateway
|
||||
org.opencontainers.image.description=Capability-gated APNs last hop for Buzz
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.push-gateway
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }}
|
||||
- name: Export digest
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}"
|
||||
- name: Upload digest
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gateway-digests-${{ matrix.arch }}
|
||||
path: /tmp/gateway-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
push-gateway-merge:
|
||||
name: Publish public push gateway image
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
needs: push-gateway-build
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Download per-arch digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/gateway-digests
|
||||
pattern: gateway-digests-*
|
||||
merge-multiple: true
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/block/buzz-push-gateway
|
||||
tags: |
|
||||
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||
- name: Merge and publish manifest
|
||||
id: manifest
|
||||
working-directory: /tmp/gateway-digests
|
||||
env:
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS"
|
||||
digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done
|
||||
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
|
||||
first_tag=$(echo "$META_TAGS" | head -n1)
|
||||
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest')
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
- name: Attest gateway image provenance
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: ghcr.io/block/buzz-push-gateway
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Gateway publication summary
|
||||
env:
|
||||
GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
GATEWAY_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "### Published \`ghcr.io/block/buzz-push-gateway\`"
|
||||
echo
|
||||
printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST"
|
||||
echo
|
||||
echo '**Tags:**'
|
||||
echo "\`\`\`"
|
||||
printf '%s\n' "$GATEWAY_TAGS"
|
||||
echo "\`\`\`"
|
||||
echo
|
||||
echo 'Verify provenance before deployment:'
|
||||
echo "\`\`\`"
|
||||
printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST"
|
||||
echo "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
259
.github/workflows/helm-chart.yml
vendored
259
.github/workflows/helm-chart.yml
vendored
@@ -1,259 +0,0 @@
|
||||
name: helm chart
|
||||
|
||||
# Lints + unit-tests + render-checks the chart on every PR/main push, and
|
||||
# PUBLISHES it to GHCR as an OCI artifact on `chart-v*` tags.
|
||||
#
|
||||
# Publishing mirrors the relay image (see docker.yml): the chart is versioned
|
||||
# independently of the desktop app and the relay via its own `chart-v*` tags
|
||||
# (Chart.yaml `version`, cut by merging a `chart-release/<version>` PR). Only
|
||||
# `chart-v*` tags publish — `main` pushes and PRs stay lint/render-only so we
|
||||
# never overwrite a released chart version from an in-progress `main`.
|
||||
#
|
||||
# Why workflow_dispatch carries version/ref inputs (same reason as docker.yml):
|
||||
# auto-tag-on-release-pr-merge.yml pushes `chart-v*` with the default
|
||||
# GITHUB_TOKEN, which GitHub's recursion guard blocks from firing any
|
||||
# `on: push` trigger. So the `push.tags` trigger below is dead for
|
||||
# auto-pushed tags — auto-tag instead dispatches this workflow with the bare
|
||||
# version + tag ref, and the publish job checks out inputs.ref and packages at
|
||||
# inputs.version. On a real manual `git push` of a `chart-v*` tag the
|
||||
# push.tags trigger fires and inputs are empty.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Chart semver e.g. 0.1.0 (no chart-v prefix) — for chart-tag rescue dispatch"
|
||||
required: false
|
||||
ref:
|
||||
description: "Chart tag ref to publish, e.g. chart-v0.1.0 (required when version is set)"
|
||||
required: false
|
||||
default: main
|
||||
push:
|
||||
# No `paths` filter here: GitHub applies a push `paths` filter to tag
|
||||
# pushes too, so a `chart-v*` tag whose commit didn't touch a chart file
|
||||
# would be filtered out and never publish. docker.yml / release.yml / sprig
|
||||
# all keep `paths` out of a tag-carrying `push` for exactly this reason —
|
||||
# PR runs stay scoped via `pull_request.paths` below; main pushes lint
|
||||
# unconditionally (cheap), and tag pushes always run so publish can fire.
|
||||
branches: [main]
|
||||
tags: ["chart-v[0-9]*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/charts/buzz/**"
|
||||
- "deploy/charts/buzz-push-gateway/**"
|
||||
- ".github/workflows/helm-chart.yml"
|
||||
- "ct.yaml"
|
||||
|
||||
# Match docker.yml: deny-by-default, each job grants only what it needs.
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
# Single source of truth for the OCI chart repository (helm appends the chart
|
||||
# name `buzz`, yielding oci://ghcr.io/block/buzz/charts/buzz, which is exactly
|
||||
# the install ref documented in deploy/charts/buzz/README.md). Set
|
||||
# GHCR_CHART_REPO as a repo variable to override (e.g., forks pushing to their
|
||||
# own namespace without editing this file) — mirrors docker.yml's GHCR_IMAGE.
|
||||
CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || 'oci://ghcr.io/block/buzz/charts' }}
|
||||
|
||||
jobs:
|
||||
lint-and-unittest:
|
||||
name: lint + unittest + render matrix
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# On chart-tag rescue dispatch, lint/render the tagged commit that the
|
||||
# publish job will package, not whatever `main` is when the dispatch
|
||||
# runs. Empty string = default ref for push/PR events.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
with:
|
||||
version: v3.16.4
|
||||
|
||||
- name: Install helm-unittest plugin
|
||||
run: helm plugin install --version 0.8.2 https://github.com/helm-unittest/helm-unittest
|
||||
|
||||
- name: Set up Python (for chart-testing)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
|
||||
- name: Build chart dependencies
|
||||
run: helm dependency build deploy/charts/buzz
|
||||
|
||||
- name: Push gateway chart lint + render guard
|
||||
run: deploy/charts/buzz-push-gateway/tests/render.sh
|
||||
|
||||
- name: ct lint
|
||||
run: ct lint --config ct.yaml --all
|
||||
|
||||
- name: helm-unittest
|
||||
run: helm unittest deploy/charts/buzz
|
||||
|
||||
- name: helm template (render every fixture)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in deploy/charts/buzz/ci/*-values.yaml deploy/charts/buzz/tests/fixtures/*-values.yaml; do
|
||||
echo "::group::render $f"
|
||||
helm template buzz deploy/charts/buzz -f "$f"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
install-on-kind:
|
||||
# Full end-to-end install requires the public ghcr.io/block/buzz image to
|
||||
# exist and to embed Max's startup migrations. Runs only after Sami's
|
||||
# image PR merges (`workflow_dispatch`) or on a schedule once main carries
|
||||
# both prerequisites. Render/lint above is the per-PR signal.
|
||||
#
|
||||
# `inputs.version == ''` excludes the chart-tag rescue dispatch (which
|
||||
# carries a version): that path only packages+publishes, it does not install.
|
||||
name: install on kind (gated)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.version == ''
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-unittest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
with:
|
||||
version: v3.16.4
|
||||
|
||||
- name: Set up Python (for chart-testing)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@0025e74a8c7512023d06dc019c617aa3cf561fde # v1.10.0
|
||||
with:
|
||||
version: v0.24.0
|
||||
node_image: kindest/node:v1.31.0
|
||||
|
||||
- name: Build chart dependencies
|
||||
run: helm dependency build deploy/charts/buzz
|
||||
|
||||
- name: ct install (quickstart profile)
|
||||
run: ct install --config ct.yaml --charts deploy/charts/buzz --helm-extra-args "--timeout 600s"
|
||||
|
||||
publish:
|
||||
# Packages the chart and pushes it to GHCR as an OCI artifact. Fires only on
|
||||
# a `chart-v*` tag push (manual) or the auto-tag rescue dispatch (which
|
||||
# carries inputs.version) — never on `main` pushes or PRs, so an in-progress
|
||||
# `main` can never overwrite a released chart version. Mirrors docker.yml's
|
||||
# GHCR publish (login with GITHUB_TOKEN, packages: write, fork override var).
|
||||
name: publish chart to GHCR
|
||||
if: >
|
||||
startsWith(github.ref, 'refs/tags/chart-v') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.version != '')
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-and-unittest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # push the chart to GHCR
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# On the rescue dispatch, build the tagged commit (github.ref is
|
||||
# `main` there); on a tag push, the default ref is already the tag.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
with:
|
||||
version: v3.16.4
|
||||
|
||||
- name: Resolve chart version
|
||||
id: ver
|
||||
env:
|
||||
# Bare on the rescue dispatch; empty on a tag push (derive from ref).
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$INPUT_VERSION" ]; then
|
||||
version="$INPUT_VERSION"
|
||||
tag_ref="refs/tags/chart-v${version}"
|
||||
tag_sha="$(git rev-parse -q --verify "${tag_ref}^{commit}" || true)"
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
if [ -z "$tag_sha" ] || [ "$head_sha" != "$tag_sha" ]; then
|
||||
echo "::error::workflow_dispatch with version '$version' must check out matching tag 'chart-v${version}' (HEAD=$head_sha, tag=${tag_sha:-missing})"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# refs/tags/chart-v0.1.0 → github.ref_name is `chart-v0.1.0`.
|
||||
version="${REF_NAME#chart-v}"
|
||||
fi
|
||||
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "::error::Resolved chart version '$version' is not valid semver"
|
||||
exit 1
|
||||
fi
|
||||
# The tag is the source of truth, but the published artifact's version
|
||||
# comes from Chart.yaml — they must agree or we'd publish a mislabeled
|
||||
# chart. Fail loudly on drift rather than silently shipping a mismatch.
|
||||
chart_version="$(helm show chart deploy/charts/buzz | awk '/^version:/ {print $2}')"
|
||||
if [ "$chart_version" != "$version" ]; then
|
||||
echo "::error::Tag version '$version' != Chart.yaml version '$chart_version'. Bump Chart.yaml to match the tag."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing chart version $version"
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build chart dependencies
|
||||
run: helm dependency build deploy/charts/buzz
|
||||
|
||||
- name: Package chart
|
||||
run: helm package deploy/charts/buzz --destination dist
|
||||
|
||||
- name: Push chart to GHCR
|
||||
env:
|
||||
CHART_REPO: ${{ env.CHART_REPO }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helm push "dist/buzz-${VERSION}.tgz" "$CHART_REPO"
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
CHART_REPO: ${{ env.CHART_REPO }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
# CHART_REPO is oci://ghcr.io/block/buzz/charts; helm push appends the
|
||||
# chart name, so the install ref is .../charts/buzz.
|
||||
INSTALL_REF="${CHART_REPO}/buzz"
|
||||
{
|
||||
echo "### Published chart \`buzz\` \`${VERSION}\`"
|
||||
echo
|
||||
echo "**OCI ref:** \`${INSTALL_REF}:${VERSION}\`"
|
||||
echo
|
||||
echo "Install:"
|
||||
echo '```'
|
||||
echo "helm install buzz ${INSTALL_REF} --version ${VERSION}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
68
.github/workflows/push-gateway-helm-chart.yml
vendored
68
.github/workflows/push-gateway-helm-chart.yml
vendored
@@ -1,68 +0,0 @@
|
||||
name: push gateway helm chart
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Chart semver (without push-chart-v prefix)"
|
||||
required: true
|
||||
ref:
|
||||
description: "Matching push-chart-v tag"
|
||||
required: true
|
||||
push:
|
||||
tags: ["push-chart-v[0-9]*"]
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/charts/buzz-push-gateway/**"
|
||||
- ".github/workflows/push-gateway-helm-chart.yml"
|
||||
permissions: {}
|
||||
env:
|
||||
CHART_REPO: oci://ghcr.io/block/buzz/charts
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: { contents: read }
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
fetch-depth: 0
|
||||
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
|
||||
with: { version: v3.16.4 }
|
||||
- run: deploy/charts/buzz-push-gateway/tests/render.sh
|
||||
- run: deploy/charts/buzz-push-gateway/tests/release-contract.sh
|
||||
publish:
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
|
||||
with: { version: v3.16.4 }
|
||||
- name: Verify release tag and chart version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]
|
||||
test "$(helm show chart deploy/charts/buzz-push-gateway | awk '/^version:/ {print $2}')" = "$version"
|
||||
if [ -n "$INPUT_VERSION" ]; then
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/push-chart-v${version}^{commit}")"
|
||||
fi
|
||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: helm package deploy/charts/buzz-push-gateway --destination dist
|
||||
- run: helm push "dist/buzz-push-gateway-${VERSION}.tgz" "$CHART_REPO"
|
||||
948
.github/workflows/release.yml
vendored
948
.github/workflows/release.yml
vendored
@@ -1,948 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Semver version (e.g. 0.4.0) — only for manual runs"
|
||||
required: true
|
||||
ref:
|
||||
description: "Branch, tag, or SHA to build — only for manual runs"
|
||||
default: main
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
# Shared setup: determine the version and create the release objects all four
|
||||
# platform jobs upload into. Runs on ubuntu with no OIDC so a branch
|
||||
# workflow_dispatch can exercise the platform builds without the codesign
|
||||
# AssumeRole wall that gates the arm64 job.
|
||||
setup:
|
||||
name: Setup
|
||||
if: github.repository == 'block/buzz'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Determine version
|
||||
id: version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="$INPUT_VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate version
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Checkout at the same ref the platform jobs use so git rev-parse HEAD
|
||||
# pins the release to the right commit via --target.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create versioned GitHub release
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
NOTES=""
|
||||
if [[ -f CHANGELOG.md ]]; then
|
||||
NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md)
|
||||
fi
|
||||
if [[ -z "$NOTES" ]]; then
|
||||
NOTES="Buzz Desktop v${VERSION}"
|
||||
fi
|
||||
PRERELEASE_FLAGS=()
|
||||
if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then
|
||||
PRERELEASE_FLAGS=(--prerelease --latest=false)
|
||||
fi
|
||||
gh release create "v${VERSION}" \
|
||||
--target "$RELEASE_SHA" \
|
||||
--title "Buzz Desktop v${VERSION}" \
|
||||
--notes "$NOTES" \
|
||||
"${PRERELEASE_FLAGS[@]}"
|
||||
|
||||
- name: Create rolling auto-update release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create buzz-desktop-latest \
|
||||
--prerelease \
|
||||
--title "Buzz Desktop Auto-Update" \
|
||||
--notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
|
||||
2>/dev/null || true
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: github.repository == 'block/buzz'
|
||||
runs-on: macos-latest
|
||||
needs: setup
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # required by block/apple-codesign-action for OIDC
|
||||
outputs:
|
||||
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||
sig: ${{ steps.read-sig.outputs.sig }}
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
|
||||
- name: Patch version
|
||||
run: |
|
||||
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||
cd src-tauri && cargo update --workspace
|
||||
|
||||
- name: Generate release config
|
||||
run: cd desktop && node scripts/build-release-config.mjs
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
|
||||
- name: Build sidecars
|
||||
run: |
|
||||
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||
./scripts/bundle-sidecars.sh
|
||||
|
||||
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
|
||||
- name: Resolve mesh-llm rev
|
||||
id: mesh_rev
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
|
||||
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore mesh llama build cache
|
||||
id: llama_cache
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build mesh llama native libraries
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||
SHORT="$MESH_REV_SHORT"
|
||||
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
|
||||
if [[ -z "$MESH_ROOT" ]]; then
|
||||
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
|
||||
exit 1
|
||||
fi
|
||||
export LLAMA_STAGE_BACKEND=metal
|
||||
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
- name: Save mesh llama build cache
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
|
||||
- name: Build unsigned Tauri app
|
||||
run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||
LLAMA_STAGE_BACKEND: metal
|
||||
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
- name: Locate unsigned DMG
|
||||
id: unsigned
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||
if [[ -z "$DMG" ]]; then
|
||||
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set DMG Finder label text size
|
||||
env:
|
||||
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
|
||||
|
||||
- name: Codesign and Notarize
|
||||
id: codesign
|
||||
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
|
||||
with:
|
||||
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
|
||||
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
|
||||
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
|
||||
entitlements-plist-path: desktop/src-tauri/Entitlements.plist
|
||||
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-arm64
|
||||
|
||||
- name: Replace DMG and rebuild updater archive
|
||||
env:
|
||||
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
|
||||
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
|
||||
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||
APP_DIR="${BUNDLE_DIR}/macos"
|
||||
|
||||
# Replace unsigned DMG with the signed/notarized one.
|
||||
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
|
||||
|
||||
# Swap the unsigned .app for the signed .app extracted from the action's zip.
|
||||
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
|
||||
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
|
||||
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
|
||||
rm -rf "${APP_DIR}/Buzz.app"
|
||||
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
|
||||
|
||||
# Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key.
|
||||
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
|
||||
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
|
||||
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
|
||||
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
|
||||
|
||||
- name: Verify code signature
|
||||
run: |
|
||||
codesign --verify --deep --strict --verbose=2 \
|
||||
desktop/src-tauri/target/release/bundle/macos/Buzz.app
|
||||
spctl --assess --type execute --verbose=4 \
|
||||
desktop/src-tauri/target/release/bundle/macos/Buzz.app
|
||||
|
||||
- name: Locate build artifacts
|
||||
id: artifacts
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||
|
||||
# Find the DMG (Tauri names it Buzz_<version>_<arch>.dmg)
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||
if [[ -z "$DMG" ]]; then
|
||||
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Find the updater .tar.gz and .sig
|
||||
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||
SIG="${ARCHIVE}.sig"
|
||||
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Read updater signature
|
||||
id: read-sig
|
||||
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
- name: Upload arm64 DMG to versioned GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
|
||||
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
|
||||
|
||||
- name: Upload updater archive to rolling release
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
|
||||
run: |
|
||||
gh release upload buzz-desktop-latest \
|
||||
"$ARCHIVE_PATH" \
|
||||
"$SIG_PATH" \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
release-macos-x64:
|
||||
name: Release macOS (Intel)
|
||||
if: github.repository == 'block/buzz'
|
||||
runs-on: macos-latest
|
||||
needs: setup
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # required by block/apple-codesign-action for OIDC
|
||||
outputs:
|
||||
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||
sig: ${{ steps.read-sig.outputs.sig }}
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
TARGET: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
|
||||
- name: Add Rust target
|
||||
run: rustup target add "$TARGET"
|
||||
|
||||
- name: Patch version
|
||||
run: |
|
||||
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||
cd src-tauri && cargo update --workspace
|
||||
|
||||
- name: Generate release config
|
||||
run: cd desktop && node scripts/build-release-config.mjs
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
|
||||
- name: Build sidecars
|
||||
run: |
|
||||
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||
./scripts/bundle-sidecars.sh "$TARGET"
|
||||
|
||||
- name: Build unsigned Tauri app
|
||||
run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||
|
||||
- name: Locate unsigned DMG
|
||||
id: unsigned
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||
if [[ -z "$DMG" ]]; then
|
||||
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set DMG Finder label text size
|
||||
env:
|
||||
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
|
||||
|
||||
- name: Codesign and Notarize
|
||||
id: codesign
|
||||
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
|
||||
with:
|
||||
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
|
||||
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
|
||||
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
|
||||
entitlements-plist-path: desktop/src-tauri/Entitlements.plist
|
||||
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-x64
|
||||
|
||||
- name: Replace DMG and rebuild updater archive
|
||||
env:
|
||||
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
|
||||
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
|
||||
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos"
|
||||
|
||||
# Replace the unsigned DMG with the signed/notarized one.
|
||||
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
|
||||
|
||||
# Swap the unsigned .app for the signed .app from the action's zip.
|
||||
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract-x64"
|
||||
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
|
||||
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
|
||||
rm -rf "${APP_DIR}/Buzz.app"
|
||||
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
|
||||
|
||||
# Rebuild the updater archive from the signed .app and re-sign with the Tauri updater key.
|
||||
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
|
||||
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
|
||||
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
|
||||
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
|
||||
|
||||
- name: Verify code signature
|
||||
run: |
|
||||
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_DIR"
|
||||
spctl --assess --type execute --verbose=4 "$APP_DIR"
|
||||
|
||||
- name: Locate updater archive
|
||||
id: artifacts
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||
|
||||
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||
SIG="${ARCHIVE}.sig"
|
||||
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Read updater signature
|
||||
id: read-sig
|
||||
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
- name: Upload Intel DMG to versioned GitHub release
|
||||
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||
|
||||
- name: Upload updater archive to rolling release
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
|
||||
run: |
|
||||
gh release upload buzz-desktop-latest \
|
||||
"$ARCHIVE_PATH" \
|
||||
"$SIG_PATH" \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
release-linux:
|
||||
name: Release Linux
|
||||
if: github.repository == 'block/buzz'
|
||||
runs-on: ubuntu-latest
|
||||
# Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh.
|
||||
container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982
|
||||
needs: setup
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
# AppImage tools (linuxdeploy, appimagetool) are themselves AppImages.
|
||||
# Containers lack FUSE, so we must use the extract-and-run fallback.
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
# This job runs in a container where the default run shell is dash;
|
||||
# the AppImage steps below use bash-only syntax ([[ ]], mapfile, arrays).
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
archive_name: ${{ steps.linux-artifacts.outputs.archive_name }}
|
||||
sig: ${{ steps.read-sig.outputs.sig }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
# Must run first: bare ubuntu:22.04 ships without curl, wget, git, or
|
||||
# ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs
|
||||
# both), and actions/checkout falls back to a REST tarball without git.
|
||||
# Running as root — no sudo needed.
|
||||
apt-get update \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30
|
||||
apt-get install -y --no-install-recommends \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
-o DPkg::Lock::Timeout=120 \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
desktop-file-utils \
|
||||
file \
|
||||
git \
|
||||
libasound2-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
patchelf \
|
||||
pkg-config \
|
||||
squashfs-tools \
|
||||
wget \
|
||||
xdg-utils
|
||||
# Install GitHub CLI — preinstalled on runners but absent in containers.
|
||||
# wget and ca-certificates are now available from the step above.
|
||||
mkdir -p -m 755 /etc/apt/keyrings
|
||||
wget -q --tries=3 --timeout=30 -O /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
https://cli.github.com/packages/githubcli-archive-keyring.gpg
|
||||
# Pin the keyring like appimagetool below. If GitHub rotates the
|
||||
# keyring this fails loudly — recompute and update the hash.
|
||||
echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /usr/share/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c
|
||||
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends gh
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Mark workspace safe for git (containerized job)
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: desktop/src-tauri
|
||||
lookup-only: true
|
||||
|
||||
- name: Install appimagetool
|
||||
run: |
|
||||
# Pin to an immutable release tag to avoid supply-chain drift from the
|
||||
# mutable `continuous` tag. Tag: 1.9.1, asset: appimagetool-<arch>.AppImage
|
||||
# (https://github.com/AppImage/appimagetool/releases/tag/1.9.1)
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||
*)
|
||||
echo "::error::Unsupported architecture: $(uname -m)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage"
|
||||
# SHA256 integrity check. Refuse to run an unverified binary: if a new
|
||||
# arch (e.g. aarch64) is enabled in CI, compute its hash and add it here.
|
||||
if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then
|
||||
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c
|
||||
else
|
||||
echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture"
|
||||
exit 1
|
||||
fi
|
||||
install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool
|
||||
# appimagetool otherwise fetches the AppImage type2 runtime from the
|
||||
# MUTABLE `continuous` tag at repack time — the runtime is the first
|
||||
# code users execute, so pin it too. Tag: 20251108, hash is for the
|
||||
# x86_64 asset (non-x86_64 already hard-fails above).
|
||||
# (https://github.com/AppImage/type2-runtime/releases/tag/20251108)
|
||||
wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \
|
||||
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}"
|
||||
echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c
|
||||
install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime
|
||||
echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: just desktop-install-ci
|
||||
|
||||
- name: Patch version
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
run: |
|
||||
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||
cd src-tauri && cargo update --workspace
|
||||
|
||||
- name: Build sidecars
|
||||
run: |
|
||||
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||
./scripts/bundle-sidecars.sh
|
||||
|
||||
- name: Generate release config
|
||||
run: cd desktop && node scripts/build-release-config.mjs
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
|
||||
- name: Build Linux Tauri app
|
||||
run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Fix AppImage (remove infra libs, symlink system GStreamer)
|
||||
run: |
|
||||
mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f)
|
||||
if [[ ${#APPIMAGES[@]} -eq 0 ]]; then
|
||||
echo "::error::No AppImage found to post-process"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${#APPIMAGES[@]} -gt 1 ]]; then
|
||||
echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}"
|
||||
exit 1
|
||||
fi
|
||||
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Locate Linux build artifacts
|
||||
id: linux-artifacts
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||
|
||||
DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1)
|
||||
if [[ -z "$DEB" ]]; then
|
||||
echo "::error::No DEB found in $BUNDLE_DIR/deb"
|
||||
exit 1
|
||||
fi
|
||||
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
|
||||
|
||||
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1)
|
||||
if [[ -z "$APPIMAGE" ]]; then
|
||||
echo "::error::No AppImage found in $BUNDLE_DIR/appimage"
|
||||
exit 1
|
||||
fi
|
||||
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Updater archive: Tauri 2.11+ with createUpdaterArtifacts signs the
|
||||
# AppImage directly (*.AppImage + *.AppImage.sig). Earlier versions
|
||||
# wrapped it in a tar.gz. Try the new format first, fall back to legacy.
|
||||
ARCHIVE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||
if [[ -n "$ARCHIVE" ]]; then
|
||||
SIG="${ARCHIVE}.sig"
|
||||
else
|
||||
ARCHIVE="$APPIMAGE"
|
||||
SIG="${APPIMAGE}.sig"
|
||||
fi
|
||||
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||
echo "::error::AppImage updater archive or signature not found in $BUNDLE_DIR/appimage"
|
||||
exit 1
|
||||
fi
|
||||
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Read updater signature
|
||||
id: read-sig
|
||||
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
|
||||
|
||||
# NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux)
|
||||
- name: Upload Linux artifacts to versioned GitHub release
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }}
|
||||
APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }}
|
||||
run: |
|
||||
gh release upload "v$VERSION" \
|
||||
"$DEB_PATH" \
|
||||
"$APPIMAGE_PATH" \
|
||||
--clobber
|
||||
|
||||
- name: Upload updater archive to rolling release
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
|
||||
run: |
|
||||
gh release upload buzz-desktop-latest \
|
||||
"$ARCHIVE_PATH" \
|
||||
"$SIG_PATH" \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }}
|
||||
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
|
||||
|
||||
release-windows:
|
||||
name: Release Windows
|
||||
runs-on: windows-latest
|
||||
needs: setup
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||
sig: ${{ steps.read-sig.outputs.sig }}
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
TARGET: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
|
||||
with:
|
||||
targets: ${{ env.TARGET }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.14.1
|
||||
# Disable dependency caching: a writable cache in this release workflow
|
||||
# (contents: write, feeds a signed installer) is a poisoning vector. pnpm
|
||||
# install runs uncached below.
|
||||
package-manager-cache: false
|
||||
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
with:
|
||||
version: 11.4.0
|
||||
|
||||
- name: Install desktop dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Patch version
|
||||
shell: bash
|
||||
run: |
|
||||
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||
cd src-tauri && cargo update --workspace
|
||||
|
||||
- name: Generate release config
|
||||
shell: bash
|
||||
run: cd desktop && node scripts/build-release-config.mjs
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
|
||||
- name: Build sidecars
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||
./scripts/bundle-sidecars.sh "$TARGET"
|
||||
|
||||
- name: Build Windows NSIS installer (unsigned)
|
||||
shell: bash
|
||||
run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
|
||||
- name: Locate Windows build artifacts
|
||||
id: artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||
|
||||
# Find the NSIS installer .exe
|
||||
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
|
||||
if [[ -z "$EXE" ]]; then
|
||||
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tauri 2.x with createUpdaterArtifacts: true signs the NSIS
|
||||
# installer in place (<name>-setup.exe + <name>-setup.exe.sig).
|
||||
SIG="${EXE}.sig"
|
||||
if [[ ! -f "$SIG" ]]; then
|
||||
echo "::error::NSIS installer signature not found: $SIG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Rename with _alpha-unsigned marker, keeping the detached signature
|
||||
# in lockstep so latest.json matches the uploaded updater artifact.
|
||||
EXE_DIR=$(dirname "$EXE")
|
||||
EXE_BASE=$(basename "$EXE" .exe)
|
||||
MARKED_EXE="${EXE_DIR}/${EXE_BASE}_alpha-unsigned.exe"
|
||||
MARKED_SIG="${MARKED_EXE}.sig"
|
||||
mv "$EXE" "$MARKED_EXE"
|
||||
mv "$SIG" "$MARKED_SIG"
|
||||
echo "exe=$MARKED_EXE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive=$MARKED_EXE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive_name=$(basename "$MARKED_EXE")" >> "$GITHUB_OUTPUT"
|
||||
echo "sig=$MARKED_SIG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Read updater signature
|
||||
id: read-sig
|
||||
shell: bash
|
||||
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
- name: Upload Windows installer to versioned GitHub release
|
||||
shell: bash
|
||||
run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EXE_PATH: ${{ steps.artifacts.outputs.exe }}
|
||||
|
||||
- name: Upload updater archive to rolling release
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version))
|
||||
shell: bash
|
||||
run: |
|
||||
gh release upload buzz-desktop-latest \
|
||||
"$ARCHIVE_PATH" \
|
||||
"$SIG_PATH" \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
|
||||
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||
|
||||
assemble-manifest:
|
||||
name: Assemble multi-platform latest.json
|
||||
# Only tag-backed releases assemble latest.json. Branch workflow_dispatch
|
||||
# runs skip rolling-release uploads, so their archive URLs never exist.
|
||||
if: |
|
||||
always() &&
|
||||
needs.setup.result == 'success' &&
|
||||
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.ref == format('v{0}', inputs.version)))
|
||||
runs-on: ubuntu-latest
|
||||
needs: [setup, release, release-macos-x64, release-linux, release-windows]
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.setup.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Write signature files
|
||||
env:
|
||||
RESULT_ARM64: ${{ needs.release.result }}
|
||||
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||
SIG_ARM64: ${{ needs.release.outputs.sig }}
|
||||
SIG_X64: ${{ needs.release-macos-x64.outputs.sig }}
|
||||
SIG_LINUX: ${{ needs.release-linux.outputs.sig }}
|
||||
SIG_WIN: ${{ needs.release-windows.outputs.sig }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/sigs
|
||||
|
||||
write_sig() {
|
||||
local result="$1" platform="$2" sig="$3"
|
||||
if [[ "$result" == "success" ]]; then
|
||||
[[ -n "$sig" ]] || { echo "::error::Missing signature for successful platform: $platform"; exit 1; }
|
||||
printf '%s' "$sig" > "/tmp/sigs/${platform}.sig"
|
||||
fi
|
||||
}
|
||||
|
||||
write_sig "$RESULT_ARM64" darwin-aarch64 "$SIG_ARM64"
|
||||
write_sig "$RESULT_X64" darwin-x86_64 "$SIG_X64"
|
||||
write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX"
|
||||
write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN"
|
||||
|
||||
- name: Verify archive URLs are accessible
|
||||
env:
|
||||
RESULT_ARM64: ${{ needs.release.result }}
|
||||
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
|
||||
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
|
||||
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
|
||||
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
|
||||
ARCHIVES=()
|
||||
|
||||
add_archive() {
|
||||
local result="$1" platform="$2" archive="$3"
|
||||
if [[ "$result" == "success" ]]; then
|
||||
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
|
||||
ARCHIVES+=("$archive")
|
||||
fi
|
||||
}
|
||||
|
||||
add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
|
||||
add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
|
||||
add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
|
||||
add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
|
||||
|
||||
for name in "${ARCHIVES[@]}"; do
|
||||
echo "Checking $BASE/$name ..."
|
||||
success=false
|
||||
for attempt in 1 2 3; do
|
||||
if curl -fsI "$BASE/$name" > /dev/null 2>&1; then
|
||||
success=true
|
||||
break
|
||||
fi
|
||||
echo "Attempt $attempt failed for $name, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ "$success" != "true" ]; then
|
||||
echo "::error::Archive not accessible after 3 attempts: $BASE/$name"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All archive URLs verified."
|
||||
|
||||
- name: Generate unified latest.json
|
||||
env:
|
||||
RESULT_ARM64: ${{ needs.release.result }}
|
||||
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
|
||||
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
|
||||
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
|
||||
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
|
||||
TRIPLES=()
|
||||
|
||||
add_triple() {
|
||||
local result="$1" platform="$2" archive="$3"
|
||||
if [[ "$result" == "success" ]]; then
|
||||
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
|
||||
TRIPLES+=("${platform}:/tmp/sigs/${platform}.sig:${BASE}/${archive}")
|
||||
fi
|
||||
}
|
||||
|
||||
add_triple "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
|
||||
add_triple "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
|
||||
add_triple "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
|
||||
add_triple "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
|
||||
|
||||
[ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; }
|
||||
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
|
||||
cat latest.json
|
||||
|
||||
- name: Upload latest.json to rolling release
|
||||
run: |
|
||||
gh release upload buzz-desktop-latest latest.json --clobber
|
||||
201
.github/workflows/sprig.yml
vendored
201
.github/workflows/sprig.yml
vendored
@@ -1,201 +0,0 @@
|
||||
name: Sprig
|
||||
|
||||
# Builds and publishes Sprig — one deploy-anywhere Linux multicall binary for:
|
||||
# buzz-acp ACP harness that bridges Buzz events to the LLM agent
|
||||
# buzz-agent ACP-compliant agent (spawns MCP, calls LLMs)
|
||||
# buzz-dev-mcp Developer MCP server (multicall: rg, tree, buzz,
|
||||
# git-credential-nostr, git-sign-nostr)
|
||||
#
|
||||
# Targets: x86_64-unknown-linux-musl and aarch64-unknown-linux-musl (static
|
||||
# musl so the tarball runs on any modern Linux without libc surprises).
|
||||
#
|
||||
# Triggers:
|
||||
# - push to main → updates rolling `sprig-latest` release
|
||||
# - tag `sprig-v*` → versioned release
|
||||
# - workflow_dispatch → manual canary build (no release publish unless asked)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["sprig-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish to the rolling release (otherwise artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- x86_64-unknown-linux-musl
|
||||
- aarch64-unknown-linux-musl
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
|
||||
- name: Install cross
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
with:
|
||||
tool: cross@0.2.5
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \
|
||||
| jq -r '.packages[] | select(.name=="sprig") | .version')
|
||||
|
||||
REF="${GITHUB_REF#refs/tags/}"
|
||||
if [[ "$GITHUB_REF" == refs/tags/sprig-v* ]]; then
|
||||
VERSION="${REF#sprig-v}"
|
||||
CHANNEL="tag"
|
||||
else
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
VERSION="${WORKSPACE_VERSION}+git.${SHORT_SHA}"
|
||||
CHANNEL="rolling"
|
||||
fi
|
||||
{
|
||||
echo "workspace_version=$WORKSPACE_VERSION"
|
||||
echo "version=$VERSION"
|
||||
echo "channel=$CHANNEL"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved version=$VERSION channel=$CHANNEL"
|
||||
|
||||
- name: Build & package Sprig
|
||||
id: pkg
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$CHANNEL" == "tag" ]]; then
|
||||
ARCHIVE_BASENAME="sprig-${VERSION}-${TARGET}"
|
||||
else
|
||||
ARCHIVE_BASENAME="sprig-${TARGET}"
|
||||
fi
|
||||
ARCHIVE_BASENAME="$ARCHIVE_BASENAME" \
|
||||
./scripts/build-sprig.sh "$VERSION" "$TARGET"
|
||||
ARCHIVE="dist/${ARCHIVE_BASENAME}.tar.gz"
|
||||
test -f "$ARCHIVE"
|
||||
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||
echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sprig-${{ matrix.target }}
|
||||
path: |
|
||||
${{ steps.pkg.outputs.archive }}
|
||||
${{ steps.pkg.outputs.archive }}.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
publish:
|
||||
name: Publish rolling release
|
||||
needs: build
|
||||
if: |
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.publish)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Download all Sprig artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
pattern: sprig-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List assets
|
||||
run: ls -lh dist/
|
||||
|
||||
- name: Update rolling release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="sprig-latest"
|
||||
TITLE="Sprig (rolling)"
|
||||
NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)."
|
||||
|
||||
if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
gh api -X PATCH "repos/${REPO}/git/refs/tags/${TAG}" \
|
||||
-f sha="${SHA}" -F force=true >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/${REPO}/git/refs" \
|
||||
-f ref="refs/tags/${TAG}" -f sha="${SHA}" >/dev/null
|
||||
fi
|
||||
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" \
|
||||
--prerelease \
|
||||
--target "${SHA}" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES"
|
||||
else
|
||||
gh release edit "$TAG" \
|
||||
--prerelease \
|
||||
--target "${SHA}" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES"
|
||||
fi
|
||||
gh release upload "$TAG" dist/* --clobber
|
||||
|
||||
publish-tag:
|
||||
name: Publish tagged release
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/sprig-v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Download all Sprig artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
pattern: sprig-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve tag version
|
||||
id: ver
|
||||
run: |
|
||||
REF="${GITHUB_REF#refs/tags/}"
|
||||
VERSION="${REF#sprig-v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create tagged release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "$TAG" \
|
||||
--title "Sprig v${VERSION}" \
|
||||
--notes "Sprig v${VERSION} — Linux all-in-one builds of buzz-acp + buzz-agent + buzz-dev-mcp." \
|
||||
dist/*
|
||||
69
.gitignore
vendored
69
.gitignore
vendored
@@ -1,69 +0,0 @@
|
||||
# Build artifacts
|
||||
/target/
|
||||
/dist/
|
||||
|
||||
# lefthook-generated hook scripts (machine-specific)
|
||||
.hooks/
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.*.sw?
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Scratch / working files (AI reviews, notes, drafts)
|
||||
.scratch/
|
||||
|
||||
# Playwright artifacts
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
.playwright-mcp/
|
||||
|
||||
# Node modules (pnpm workspace hoists to root)
|
||||
node_modules/
|
||||
|
||||
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
|
||||
/package-lock.json
|
||||
|
||||
# sqlx offline query data (generated, not portable)
|
||||
.sqlx/
|
||||
|
||||
# SQLite database (created by relay in CWD)
|
||||
buzz.db
|
||||
buzz.db-wal
|
||||
buzz.db-shm
|
||||
|
||||
# Docker volumes (if mounted locally)
|
||||
mysql-data/
|
||||
typesense-data/
|
||||
|
||||
# Hermit (toolchain manager cache)
|
||||
.hermit/
|
||||
doc/
|
||||
/repos/
|
||||
|
||||
# Local identity files
|
||||
identity.key
|
||||
**/identity.key
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# mesh-llm build cache
|
||||
.cache/
|
||||
|
||||
# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
|
||||
deploy/charts/*/charts/*.tgz
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/screenshot_skill.md
|
||||
@@ -1 +0,0 @@
|
||||
../../../desktop/src-tauri/src/managed_agents/nest_skill.md
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"rust-analyzer.cargo.targetDir": true
|
||||
}
|
||||
561
AGENTS.md
561
AGENTS.md
@@ -1,561 +0,0 @@
|
||||
# AGENTS.md — AI Agent Contributor Guide
|
||||
|
||||
This guide is for AI agents contributing to the Buzz codebase. It covers
|
||||
agent-specific context and conventions. For general contributor info (setup,
|
||||
code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem
|
||||
|
||||
Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
|
||||
|
||||
| Repo | Purpose |
|
||||
|------|---------|
|
||||
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
|
||||
| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix |
|
||||
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
|
||||
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
|
||||
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
|
||||
|
||||
```
|
||||
block/buzz (source)
|
||||
├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
|
||||
├─► sprout-oss (relay Docker image → ECR)
|
||||
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
|
||||
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
|
||||
```
|
||||
|
||||
See [RELEASING.md](RELEASING.md) for the desktop release flow and
|
||||
[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor
|
||||
access information.
|
||||
|
||||
---
|
||||
|
||||
## Repo Structure
|
||||
|
||||
```
|
||||
crates/
|
||||
# Relay + core
|
||||
buzz-relay # WebSocket relay server — main entry point; also hosts git + huddle audio
|
||||
buzz-core # Core types, event verification, filter matching, kind registry
|
||||
buzz-db # Postgres event store and data access layer
|
||||
buzz-auth # Authentication and authorization
|
||||
buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators
|
||||
buzz-search # Postgres FTS full-text search
|
||||
buzz-audit # Hash-chain audit log
|
||||
buzz-media # Blossom/S3 media storage
|
||||
# Agent surface
|
||||
buzz-acp # ACP harness bridging Buzz events to AI agents
|
||||
buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
|
||||
buzz-dev-mcp # Developer MCP server — shell + file-edit tools
|
||||
buzz-persona # Agent persona packs
|
||||
buzz-workflow # YAML-as-code workflow engine (evalexpr conditions)
|
||||
# Clients + interop
|
||||
buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing
|
||||
buzz-pairing-cli # CLI for NIP-AB device pairing interop testing
|
||||
git-sign-nostr # Sign git objects with a Nostr key
|
||||
git-credential-nostr # Git credential helper for Nostr-authed push/fetch
|
||||
# Tooling + shared
|
||||
buzz-cli # Agent-first CLI
|
||||
buzz-sdk # Typed Nostr event builders
|
||||
buzz-admin # Operator CLI for relay administration
|
||||
buzz-ws-client # Shared NIP-42 WebSocket client (connect, auth, publish)
|
||||
buzz-test-client # Integration test client and E2E test suite
|
||||
sprig # All-in-one harness bundling ACP, agent, and dev MCP
|
||||
|
||||
desktop/ # Tauri 2 + React 19 desktop app
|
||||
web/ # Browser web client (repo browser, served by the relay)
|
||||
mobile/ # Flutter mobile app
|
||||
migrations/ # SQL migrations (auto-applied on relay startup)
|
||||
scripts/ # Dev tooling
|
||||
.env.example # Config template — copy to .env before running
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
. ./bin/activate-hermit # activate hermit toolchain (Rust, Node, etc.)
|
||||
cp .env.example .env # configure local environment
|
||||
just setup # install deps, run migrations
|
||||
just relay # start relay at ws://localhost:3000
|
||||
just ci # run before any PR
|
||||
```
|
||||
|
||||
See CONTRIBUTING.md for full setup details and dependency requirements.
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
|
||||
unit tests + builds. Clippy passing does not mean fmt passes; run both.
|
||||
|
||||
Run `just test` for integration tests if you touched `buzz-relay`,
|
||||
`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.
|
||||
|
||||
**Pre-commit hooks** are installed automatically by `just setup` and auto-fix
|
||||
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
|
||||
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
|
||||
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
|
||||
commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests
|
||||
in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with
|
||||
pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting
|
||||
in one shot. Run `just ci` for the full local gate. Run `just hooks` to
|
||||
re-install hooks after env changes.
|
||||
|
||||
Additional rules:
|
||||
- No `unsafe` code
|
||||
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
|
||||
- New public API must have doc comments
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.
|
||||
|
||||
**Prefer Nostr events over new HTTP endpoints**: For new feature work, model
|
||||
the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler
|
||||
in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is
|
||||
reserved for things that genuinely need an HTTP-only surface: media upload/download
|
||||
(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,
|
||||
and the generic Nostr bridge endpoints:
|
||||
|
||||
- `POST /events` — submit any signed event (same path the WebSocket uses).
|
||||
- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters
|
||||
are routed to `buzz-search` (Postgres FTS) automatically.
|
||||
- `POST /count` — Nostr COUNT filters over HTTP.
|
||||
|
||||
If you find yourself reaching for a new HTTP endpoint, first check whether
|
||||
an event kind would do the job — it usually will, and you get realtime
|
||||
fan-out, NIP-29 scoping, and the existing auth pipeline for free.
|
||||
|
||||
Reference https://github.com/nostr-protocol/nips
|
||||
|
||||
**Event kinds**: All event kind integers are defined in
|
||||
`buzz-core/src/kind.rs`. New features get new kind integers — add them here
|
||||
first, then implement handling in the relay.
|
||||
|
||||
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
|
||||
Filters and queries must scope to `h` tags when operating within a channel.
|
||||
|
||||
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
|
||||
|
||||
**Workflow conditions**: `buzz-workflow` uses
|
||||
[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
|
||||
simple and testable.
|
||||
|
||||
**Thread counters**: `reply_count` and `descendant_count` are materialized on
|
||||
thread root events. Any code that inserts replies must update these counters —
|
||||
check existing reply handlers for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## Agent CLI (`buzz-cli`)
|
||||
|
||||
`buzz` is the agent-first CLI. Auth env vars
|
||||
(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected
|
||||
by the ACP harness into managed agent subprocesses. In development, set
|
||||
`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.
|
||||
|
||||
### Building the CLI
|
||||
|
||||
```bash
|
||||
cargo build --release -p buzz-cli
|
||||
```
|
||||
|
||||
Binary location: `./target/release/buzz`. Add `./target/release` to `PATH`
|
||||
or invoke with the full path.
|
||||
|
||||
### Deep Links
|
||||
|
||||
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
|
||||
thread. To read the linked thread:
|
||||
|
||||
```bash
|
||||
buzz messages thread --channel <uuid> --event <hex> --format compact
|
||||
```
|
||||
|
||||
Extract `channel` and `id` from the URL query parameters. The optional
|
||||
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
|
||||
the full thread from the event ID alone.
|
||||
|
||||
All reads return sig-stripped JSON arrays; all writes return
|
||||
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
|
||||
0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).
|
||||
|
||||
`--format compact` is a **global** flag — it goes before the subcommand:
|
||||
`buzz --format compact channels list`, NOT `buzz channels list --format compact`.
|
||||
|
||||
See `crates/buzz-cli/TESTING.md` for the full live-testing runbook.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
just test-unit # unit tests, no infrastructure needed
|
||||
just test # full integration suite (requires Postgres + Redis)
|
||||
```
|
||||
|
||||
E2E tests live in `crates/buzz-test-client/tests/`:
|
||||
- `e2e_relay.rs` — WebSocket relay protocol
|
||||
- `e2e_media.rs` — media upload/download (Blossom)
|
||||
- `e2e_media_extended.rs` — extended media scenarios
|
||||
- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)
|
||||
|
||||
Desktop E2E: `cd desktop && pnpm exec playwright test`
|
||||
|
||||
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
||||
|
||||
### PR Screenshots
|
||||
|
||||
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
|
||||
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
|
||||
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
|
||||
> from a PR body/comment. If you hand-edit PR markdown, run
|
||||
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
|
||||
|
||||
For mobile simulator screenshots, save the PNGs in a local directory and run
|
||||
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
|
||||
with a markdown template containing `{{filename}}` placeholders.
|
||||
|
||||
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
|
||||
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
|
||||
starts preview server, runs Playwright automatically):
|
||||
|
||||
```bash
|
||||
just desktop-screenshot --name home
|
||||
just desktop-screenshot --name channel --route /channels/general
|
||||
just desktop-screenshot --name search --click open-search
|
||||
just desktop-screenshot --name settings --click open-settings
|
||||
```
|
||||
|
||||
Options: `--name` (filename), `--route` (client route), `--active-channel`
|
||||
(channel to view), `--click` (left-click data-testid or CSS selector),
|
||||
`--right-click` (right-click for context menus), `--hover` (hover before
|
||||
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
|
||||
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
|
||||
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
|
||||
Output is a PNG path on stdout.
|
||||
|
||||
Use `--messages` to inject content into a channel before capture. The JSON file
|
||||
is an array of objects — `channelName` and `content` are required, all other
|
||||
fields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"channelName": "random",
|
||||
"content": "Hey @tyler check this out",
|
||||
"pubkey": "953d...",
|
||||
"kind": 40002,
|
||||
"mentionPubkeys": ["deadbeef..."],
|
||||
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
|
||||
"parentEventId": "abc123"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Without `--active-channel`, all messages must target the same channel and the
|
||||
helper navigates to that channel (useful for showing message content). With
|
||||
`--active-channel`, messages can target multiple channels while the "camera"
|
||||
stays on the specified channel (useful for unread indicators, badges, etc.).
|
||||
|
||||
```bash
|
||||
# Messages in the channel you're viewing (code blocks, formatting, etc.)
|
||||
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
|
||||
|
||||
# Messages in OTHER channels to trigger unread state
|
||||
just desktop-screenshot --name unread-dot \
|
||||
--active-channel general --messages /tmp/badge-msgs.json
|
||||
|
||||
# Cropped to sidebar only (256px wide)
|
||||
just desktop-screenshot --name sidebar-unread \
|
||||
--active-channel general --messages /tmp/badge-msgs.json \
|
||||
--clip 0,0,256,720
|
||||
|
||||
# Context menu on an unread channel (wider crop to include popup)
|
||||
just desktop-screenshot --name ctx-mark-read \
|
||||
--active-channel general --messages /tmp/badge-msgs.json \
|
||||
--right-click channel-random --clip 0,200,320,300
|
||||
|
||||
# Hover state (e.g. copy button reveal)
|
||||
just desktop-screenshot --name copy-hover \
|
||||
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
|
||||
```
|
||||
|
||||
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
|
||||
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
|
||||
|
||||
`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch
|
||||
(`agent-screenshots/<github-username>`) and posts a PR comment with
|
||||
commit-SHA-based image URLs (immutable — safe from later overwrites):
|
||||
|
||||
```bash
|
||||
./scripts/post-screenshots.sh 803 test-results/screenshots
|
||||
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
|
||||
```
|
||||
|
||||
The body file supports `{{filename}}` placeholders (without `.png`) to inline
|
||||
images at specific positions. Images not referenced by any placeholder are
|
||||
appended at the end. Without placeholders, all images are appended (backward
|
||||
compatible).
|
||||
|
||||
```markdown
|
||||
### Unread dot
|
||||
A message arrives in `#random`.
|
||||
|
||||
{{01-unread-dot}}
|
||||
|
||||
### Context menu
|
||||
Right-click shows "Mark as read".
|
||||
|
||||
{{02-context-menu}}
|
||||
```
|
||||
|
||||
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
|
||||
branch, but the script **appends a new PR comment** — it does not edit or
|
||||
delete the previous one. After reposting, delete the superseded comment so
|
||||
only the current set remains, otherwise reviewers still see the stale images:
|
||||
|
||||
```bash
|
||||
# List screenshot comments to find the stale one's id
|
||||
gh pr view <pr> --repo block/buzz --json comments \
|
||||
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
|
||||
gh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>
|
||||
```
|
||||
|
||||
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
|
||||
|
||||
### Writing E2E Screenshot Specs
|
||||
|
||||
When screenshots need seeded state, live messages, or UI interaction before
|
||||
capture, write a Playwright spec instead of using `just desktop-screenshot`.
|
||||
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
|
||||
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
|
||||
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
|
||||
|
||||
**Stale server:** `reuseExistingServer: true` means a previous build's server
|
||||
serves old code. Kill port 4173 and `pnpm run build` before re-running tests
|
||||
after code changes.
|
||||
|
||||
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
|
||||
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
|
||||
bridge triggers mount.
|
||||
|
||||
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
|
||||
`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
|
||||
subscription. Navigate to the channel first (triggers subscription), then away
|
||||
(so unread indicators appear), then inject.
|
||||
|
||||
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
|
||||
resolves mid-animation — wait for completion before screenshotting. Use the
|
||||
shared helper (mandatory before any `page.screenshot()` or
|
||||
`locator.screenshot()` in specs):
|
||||
|
||||
```ts
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
// ... after the element is visible but before capturing:
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: "...", clip: { ... } });
|
||||
```
|
||||
|
||||
The `just desktop-screenshot` path (`screenshot.mjs`) calls
|
||||
`waitForAnimations` automatically — no manual step needed there.
|
||||
|
||||
For per-element waits (rare — prefer the page-level helper above):
|
||||
|
||||
```ts
|
||||
await menuItem.evaluate((el) =>
|
||||
Promise.all(
|
||||
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
|
||||
for sidebar features. Sidebar = 256px; context menus ~450px.
|
||||
|
||||
**Distinct states — verify before posting:** when one view renders many
|
||||
elements at once (e.g. all team cards in a single grid), an unscoped
|
||||
full-page `page.screenshot()` captures the *same* pixels for every shot, so
|
||||
multiple PNGs come out byte-identical. Scope each shot to its subject with
|
||||
`locator.screenshot()` (full-page `clip` only when an overlay like an open
|
||||
dropdown must be included). Then gate on hash distinctness before posting:
|
||||
|
||||
```bash
|
||||
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
|
||||
```
|
||||
|
||||
Identical hashes mean two shots captured the same state — fix the spec, do
|
||||
not post. This catches the most common screenshot regression.
|
||||
|
||||
**`general` has pre-seeded messages** making `hasUnread` always true. Use
|
||||
`engineering` for "muted + no unread" visual states.
|
||||
|
||||
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
|
||||
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
|
||||
description. See [PR #803](https://github.com/block/buzz/pull/803).
|
||||
|
||||
---
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
|
||||
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
|
||||
3. **`messages search` must include `--kinds`** — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least `--kinds 9,45001,45003` to scope the query.
|
||||
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
|
||||
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
|
||||
6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
|
||||
7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
## Desktop App
|
||||
|
||||
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
|
||||
organized under `desktop/src/features/`. Biome handles linting and formatting.
|
||||
|
||||
```bash
|
||||
just desktop-dev # web-only dev server (faster iteration)
|
||||
just dev # full Tauri app with native shell
|
||||
```
|
||||
|
||||
### Text sizing & zoom (use rem, never px)
|
||||
|
||||
The desktop app implements Cmd +/- zoom by scaling the root `<html>`
|
||||
font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native
|
||||
webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes
|
||||
are frozen.**
|
||||
|
||||
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
|
||||
px:
|
||||
|
||||
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
|
||||
text === `text-base` (16px) — chat is the app's base type size**, and the
|
||||
surrounding timeline elements (timestamps, system rows, code, reactions) are
|
||||
deliberate steps on that same stock ramp.
|
||||
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
|
||||
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
|
||||
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
|
||||
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
|
||||
apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary
|
||||
values.
|
||||
- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom
|
||||
and caused the message-timeline regression (PR #891).
|
||||
- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They
|
||||
zoom fine but re-fragment the scale we consolidated. Use a named token.
|
||||
|
||||
Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
|
||||
needs a size the stock/`2xs`/`3xs` scale can't express should you **add a
|
||||
rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)
|
||||
rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in
|
||||
`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any
|
||||
new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs
|
||||
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
|
||||
script.
|
||||
|
||||
### Workspace Switching
|
||||
|
||||
The desktop app supports multiple workspaces (each backed by a different relay).
|
||||
Switching workspaces does **not** reload the page — it uses React key-based
|
||||
remounting. `<AppReady key={workspaceKey} />` in `App.tsx` forces the entire
|
||||
workspace-scoped subtree to unmount and remount with fresh state.
|
||||
|
||||
**Module-level singletons must be explicitly reset.** React remounting only
|
||||
clears React state (useState, useRef, context). Module-level variables (Maps,
|
||||
class instances, cached promises) survive across remounts. Every workspace-scoped
|
||||
singleton needs a reset function wired into `resetWorkspaceState()` in
|
||||
`desktop/src/features/workspaces/useWorkspaceInit.ts`.
|
||||
|
||||
Current singletons that are reset on workspace switch:
|
||||
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
|
||||
- `resetMediaCaches()` — proxy port and relay origin caches
|
||||
- `clearSearchHitEventCache()` — search result event cache
|
||||
- `clearAllDrafts()` — message draft cache
|
||||
|
||||
**If you add a new module-level cache, Map, or class instance that holds
|
||||
workspace-scoped data, you must add its reset to `resetWorkspaceState()`.**
|
||||
Failure to do so causes data from the old workspace to leak into the new one.
|
||||
|
||||
Key files:
|
||||
- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary
|
||||
- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend
|
||||
- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell)
|
||||
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`)
|
||||
|
||||
---
|
||||
|
||||
## Mobile App (Flutter)
|
||||
|
||||
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
|
||||
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
|
||||
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
|
||||
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
|
||||
stay in sync with `desktop/src/shared/constants/kinds.ts`
|
||||
|
||||
### Rules
|
||||
|
||||
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
|
||||
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
|
||||
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
|
||||
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
|
||||
`dart format` are safe for agents to run.
|
||||
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
|
||||
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
|
||||
over raw `Theme.of(context)` calls.
|
||||
- **Keep widgets small and composable.** One public widget per file; push
|
||||
private sub-widgets (`_Foo`) into sibling `part` files under a
|
||||
`<page>/` folder rather than growing the page file. Hard ceiling:
|
||||
**1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via
|
||||
`just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
|
||||
If the guard trips, **split the file — never bump the limit or add an
|
||||
override to slip under it.**
|
||||
- Feature modules must not import from other feature modules — only from
|
||||
`shared/`.
|
||||
- Use `Grid` tokens for spacing, `Radii` for border radius.
|
||||
|
||||
### Quality Checks
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
|
||||
|
||||
To run the app locally (starts Docker, relay, iOS simulator automatically):
|
||||
|
||||
```bash
|
||||
just mobile-dev
|
||||
```
|
||||
|
||||
### Testing Conventions
|
||||
|
||||
- Prefer **widget tests** over unit tests for UI components — test the
|
||||
whole widget tree, not individual methods.
|
||||
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
|
||||
- Fake notifiers should extend the real notifier class and override `build()`.
|
||||
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
|
||||
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
|
||||
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
|
||||
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds
|
||||
- [README.md](README.md) — project overview and quick start
|
||||
827
ARCHITECTURE.md
827
ARCHITECTURE.md
@@ -1,827 +0,0 @@
|
||||
# Buzz Architecture
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Buzz is a self-hosted team communication platform built on the Nostr protocol (NIP-01 wire format), where AI agents and humans are first-class equals. Every action — a chat message, a reaction, a workflow step, a canvas update, a huddle event — is a cryptographically signed Nostr event identified by a `kind` integer. Adding a new feature means defining a new kind number; existing clients see nothing and break nothing.
|
||||
|
||||
The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket, and the relay enforcing auth, verifying signatures, persisting events, fanning out to subscribers, indexing for search, and triggering automation.
|
||||
|
||||
A Buzz **community** is the tenant-visible workspace selected by the request host.
|
||||
The self-hosted default remains one host, one relay process, one implicit
|
||||
community. Multi-community deployments move that semantic boundary one level up:
|
||||
`req.community = resolve_host(connection.host)` is established before AUTH,
|
||||
EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown
|
||||
hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived
|
||||
community rather than overriding it.
|
||||
|
||||
Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
|
||||
|
||||
---
|
||||
|
||||
### System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENTS │
|
||||
│ │
|
||||
│ Human (Nostr app, web, mobile) Agent (CLI tools via buzz-cli) │
|
||||
│ │ │ │
|
||||
│ └──────────── WebSocket ─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ buzz-relay (Axum) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
|
||||
│ │ NIP-42 │ │ EVENT │ │ REQ │ │ HTTP bridge │ │
|
||||
│ │ auth │ │ pipeline │ │ handler │ │ /events │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │ /query │ │
|
||||
│ │ /count │ │
|
||||
│ ┌──────────────────────────────────────┐ │ /hooks/{id} │ │
|
||||
│ │ SubscriptionRegistry │ │ /media/* │ │
|
||||
│ │ DashMap: (channel_id, kind) → conns │ │ /git/* │ │
|
||||
│ └──────────────────────────────────────┘ │ /info, NIP-05 │ │
|
||||
│ └─────────────────────┘ │
|
||||
└──────────┬──────────────┬──────────────────────────────────────────┘
|
||||
│ │
|
||||
┌─────▼──────┐ ┌────▼──────┐
|
||||
│ Postgres │ │ Redis │
|
||||
│ (events, │ │ (presence │
|
||||
│ channels, │ │ SET EX, │
|
||||
│ tokens, │ │ typing │
|
||||
│ workflows, │ │ ZADD, │
|
||||
│ audit) │ │ PUBLISH) │
|
||||
└────────────┘ └───────────┘
|
||||
|
||||
Fan-out: sub_registry.fan_out() → conn_manager.send_to()
|
||||
(in-process for local events; Redis round-trip for
|
||||
events from other relay instances)
|
||||
|
||||
Redis PUBLISH occurs for channel-scoped events.
|
||||
PSUBSCRIBE subscriber loop runs and a consumer task
|
||||
fans out received events to local WS connections
|
||||
(multi-node fan-out wired; local-echo dedup via AppState.local_event_ids).
|
||||
|
||||
┌──────────────┐
|
||||
│ Postgres │ ← buzz-search (FTS over the search_tsv
|
||||
│ (full-text │ generated column + GIN index)
|
||||
│ search) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Crate Dependency Hierarchy
|
||||
|
||||
```
|
||||
buzz-core (zero I/O — types, verification, filter matching, kind registry)
|
||||
│
|
||||
├── buzz-db (Postgres: events, channels, tokens, workflows, audit)
|
||||
├── buzz-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
|
||||
├── buzz-pubsub (Redis pub/sub, presence, typing indicators)
|
||||
├── buzz-search (Postgres FTS: query, delete)
|
||||
├── buzz-audit (hash-chain tamper-evident log)
|
||||
└── buzz-workflow (YAML-as-code automation engine)
|
||||
│
|
||||
└── buzz-relay (ties everything together — the server)
|
||||
|
||||
buzz-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
|
||||
buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli)
|
||||
buzz-media (Blossom/S3 media storage)
|
||||
buzz-cli (agent-first CLI)
|
||||
buzz-admin (operator CLI: relay membership + key generation)
|
||||
buzz-test-client (integration test harness + manual CLI)
|
||||
```
|
||||
|
||||
**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Protocol
|
||||
|
||||
Buzz uses Nostr NIP-01 on the wire. Every action is a JSON event with six fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<sha256 of canonical serialization>",
|
||||
"pubkey": "<secp256k1 public key, hex>",
|
||||
"kind": <unsigned integer>,
|
||||
"tags": [["e", "<event-id>"], ["p", "<pubkey>"], ...],
|
||||
"content": "<JSON payload or plain text>",
|
||||
"sig": "<Schnorr signature over id>"
|
||||
}
|
||||
```
|
||||
|
||||
The `kind` integer is the only dispatch switch. The relay routes, stores, and fans out events based on kind. Clients filter subscriptions by kind. New feature = new kind number = zero breaking changes to existing clients.
|
||||
|
||||
### Kind Ranges
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 0–9999 | Standard Nostr kinds (NIP-01 through NIP-XX) |
|
||||
| 10000–19999 | Replaceable events (NIP-16) |
|
||||
| 20000–29999 | Ephemeral events — not stored, not audited |
|
||||
| 30000–39999 | Parameterized replaceable events |
|
||||
| 40000–49999 | Buzz custom kinds |
|
||||
|
||||
### Buzz Custom Kinds (selected)
|
||||
|
||||
| Kind | Name | Description |
|
||||
|------|------|-------------|
|
||||
| 7 | KIND_REACTION | Emoji reaction (standard NIP-25) |
|
||||
| 9 | KIND_STREAM_MESSAGE | Chat message in a Stream channel (NIP-29 group chat) |
|
||||
| 40002 | KIND_STREAM_MESSAGE_V2 | Stream message v2 format |
|
||||
| 40003 | KIND_STREAM_MESSAGE_EDIT | Edit of a stream message |
|
||||
| 43001 | KIND_JOB_REQUEST | Agent job request |
|
||||
| 45001 | KIND_FORUM_POST | Forum thread root |
|
||||
| 45003 | KIND_FORUM_COMMENT | Forum thread reply |
|
||||
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
|
||||
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
|
||||
|
||||
`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
|
||||
|
||||
Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`.
|
||||
|
||||
### Wire Protocol (NIP-01 messages)
|
||||
|
||||
| Direction | Message | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Client → Relay | `["EVENT", <event>]` | Submit a signed event |
|
||||
| Client → Relay | `["REQ", <sub_id>, <filter>, ...]` | Subscribe to events |
|
||||
| Client → Relay | `["CLOSE", <sub_id>]` | Cancel a subscription |
|
||||
| Client → Relay | `["AUTH", <event>]` | Authenticate (NIP-42) |
|
||||
| Relay → Client | `["EVENT", <sub_id>, <event>]` | Deliver a matching event |
|
||||
| Relay → Client | `["EOSE", <sub_id>]` | End of stored events |
|
||||
| Relay → Client | `["OK", <event_id>, true/false, ""]` | Event acceptance result |
|
||||
| Relay → Client | `["CLOSED", <sub_id>, "reason"]` | Subscription closed |
|
||||
| Relay → Client | `["NOTICE", "message"]` | Informational message |
|
||||
| Relay → Client | `["AUTH", <challenge>]` | Authentication challenge |
|
||||
|
||||
Max frame size: 65,536 bytes. Max subscriptions per connection: 1024. Max historical results per filter: 500.
|
||||
|
||||
---
|
||||
|
||||
## 3. Connection Lifecycle
|
||||
|
||||
Every WebSocket connection follows this exact sequence:
|
||||
|
||||
### Step 0: Community Binding
|
||||
|
||||
The server resolves `TenantContext` from the request host before any handler can
|
||||
observe tenant data. The URL/domain is authoritative for the community, matching
|
||||
today's "the relay URL is the workspace" behavior. In single-community mode the
|
||||
configured host maps to the default community. In multi-community mode, an
|
||||
unknown or unmapped host rejects generically and never falls through to a default
|
||||
tenant. Client-supplied `#h` tags are still channel identifiers; they must resolve
|
||||
to a channel inside the host-derived community.
|
||||
|
||||
### Step 1: Semaphore Acquire
|
||||
|
||||
`state.conn_semaphore.try_acquire_owned()` — if the relay is at connection capacity, the connection is rejected immediately before any data is read. The permit is held for the entire connection lifetime and dropped on cleanup.
|
||||
|
||||
### Step 2: NIP-42 Challenge
|
||||
|
||||
The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random string. The connection is registered in `ConnectionManager` after the challenge is sent.
|
||||
|
||||
### Step 3: Authentication
|
||||
|
||||
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Authentication paths:
|
||||
|
||||
| Path | Mechanism | Use Case |
|
||||
|------|-----------|---------|
|
||||
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
|
||||
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients |
|
||||
|
||||
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
|
||||
|
||||
### Step 4: Active Loops
|
||||
|
||||
Three concurrent tasks run for the lifetime of the connection:
|
||||
|
||||
- **recv_loop** (inline): reads frames, parses `ClientMessage`, dispatches to handlers
|
||||
- **send_loop** (spawned): drains the mpsc channel, writes frames to the WebSocket
|
||||
- **heartbeat_loop** (spawned): sends WebSocket ping every 30 seconds; 3 missed pongs → disconnect
|
||||
|
||||
A `CancellationToken` coordinates shutdown across all three loops.
|
||||
|
||||
Slow clients: `ConnectionState::send()` uses `try_send` — if the send buffer is full, a grace counter increments. After `SLOW_CLIENT_GRACE_LIMIT` (3) consecutive full-buffer events, the connection is cancelled. A successful send resets the counter.
|
||||
|
||||
### Step 5: Cleanup
|
||||
|
||||
On disconnect (any cause):
|
||||
1. `cancel.cancel()` — signals all loops
|
||||
2. Await send_loop and heartbeat_loop tasks
|
||||
3. `sub_registry.remove_connection(conn_id)` — removes all subscriptions from the DashMap indexes
|
||||
4. `conn_manager.deregister(conn_id)` — removes from the send-channel map
|
||||
5. `drop(permit)` — releases the connection semaphore slot
|
||||
|
||||
---
|
||||
|
||||
## 4. Event Pipeline
|
||||
|
||||
When the relay receives `["EVENT", <event>]`, the handler in `handlers/event.rs` runs this pipeline in order:
|
||||
|
||||
```
|
||||
1. AUTH CHECK — AuthState::Authenticated? MessagesWrite scope?
|
||||
2. PUBKEY MATCH — event.pubkey == auth_context.pubkey?
|
||||
3. KIND_AUTH REJECT — kind == 22242 (AUTH events never stored)
|
||||
4. EPHEMERAL ROUTE — kind 20000–29999 → ephemeral sub-pipeline (see below)
|
||||
5. VERIFY — spawn_blocking(verify_event) — Schnorr sig + ID hash
|
||||
6. MEMBERSHIP — channel_id in event tags? → check_channel_membership
|
||||
7. DB INSERT — db.insert_event (ON CONFLICT DO NOTHING — idempotent)
|
||||
8. REDIS PUBLISH — pubsub.publish_event (if channel-scoped)
|
||||
9. FAN-OUT — sub_registry.fan_out → conn_manager.send_to
|
||||
10. SEARCH INDEX — search_index_tx.send (bounded worker queue, non-blocking)
|
||||
11. AUDIT LOG — audit.log (spawned async, non-blocking)
|
||||
12. WORKFLOW TRIGGER — wf.on_event (spawned async, excludes kinds 46001–46012)
|
||||
```
|
||||
|
||||
Steps 10–12 are fire-and-forget. Search indexing is sent to a bounded worker queue (`search_index_tx`, capacity 1000); audit and workflow triggers are spawned as independent async tasks. A failure in any of these does not fail the event submission. The client receives `["OK", <id>, true, ""]` at the end of the pipeline, not immediately after DB insert.
|
||||
|
||||
Step 9 (fan-out) explicitly **excludes** global subscriptions (no `channel_id` constraint) from channel-scoped events — global subscriptions do NOT receive events from private channels, regardless of filter match. This is a deliberate security boundary: only subscriptions scoped to an accessible `channel_id` receive those events.
|
||||
|
||||
Workflow loop prevention: workflow execution kinds (46001–46012), relay-signed messages with `buzz:workflow` tag, and `KIND_GIFT_WRAP` are excluded from triggering workflows. All other stored events (including kind 9 stream messages) trigger workflow evaluation.
|
||||
|
||||
### Ephemeral Sub-Pipeline (kinds 20000–29999)
|
||||
|
||||
Ephemeral events bypass DB storage, audit, and search. Two sub-paths:
|
||||
|
||||
**Presence events (kind 20001):**
|
||||
```
|
||||
1. VERIFY — spawn_blocking(verify_event)
|
||||
2. REDIS PRESENCE — set_presence() or clear_presence() based on content
|
||||
3. LOCAL FAN-OUT — sub_registry.fan_out → conn_manager.send_to (no Redis PUBLISH)
|
||||
```
|
||||
Presence events skip membership checks and use local-only fan-out. Multi-node presence fan-out would require Redis pub/sub (documented as future work).
|
||||
|
||||
**Other ephemeral events (e.g., typing indicators):**
|
||||
```
|
||||
1. VERIFY — spawn_blocking(verify_event)
|
||||
2. MEMBERSHIP — check_channel_membership (if channel-scoped)
|
||||
3. MARK LOCAL — state.mark_local_event (dedup before Redis round-trip)
|
||||
4. REDIS PUBLISH — pubsub.publish_event (no DB write)
|
||||
5. LOCAL FAN-OUT — sub_registry.fan_out → conn_manager.send_to
|
||||
```
|
||||
|
||||
Ephemeral events are never stored in Postgres and never appear in REQ historical queries.
|
||||
|
||||
### Handler Semaphore
|
||||
|
||||
Beyond the per-connection semaphore, a `handler_semaphore` (capacity 1024) limits concurrent EVENT and REQ processing across all connections. CLOSE is not rate-limited.
|
||||
|
||||
---
|
||||
|
||||
## 5. Subscription System
|
||||
|
||||
### SubscriptionRegistry
|
||||
|
||||
The subscription registry is a DashMap-backed structure in `subscription.rs`:
|
||||
|
||||
```rust
|
||||
pub struct SubscriptionRegistry {
|
||||
subs: DashMap<ConnId, HashMap<SubId, SubEntry>>,
|
||||
channel_kind_index: DashMap<IndexKey, Vec<(ConnId, SubId)>>,
|
||||
channel_wildcard_index: DashMap<Uuid, Vec<(ConnId, SubId)>>,
|
||||
}
|
||||
|
||||
pub struct IndexKey {
|
||||
pub channel_id: Uuid,
|
||||
pub kind: Kind,
|
||||
}
|
||||
```
|
||||
|
||||
### Three-Tier Fan-Out
|
||||
|
||||
When an event arrives, `fan_out` consults three indexes in order:
|
||||
|
||||
| Tier | Index | Key | Use Case |
|
||||
|------|-------|-----|---------|
|
||||
| 1 | `channel_kind_index` | `(channel_id, kind)` | Subs with explicit channel + kind filter — O(1) lookup |
|
||||
| 2 | `channel_wildcard_index` | `channel_id` | Subs with channel but no `kinds` constraint |
|
||||
| 3 | `subs` (linear scan) | — | Global subs (no channel_id) — fallback scan |
|
||||
|
||||
Global subs (tier 3) are checked for non-channel-scoped events only. Channel-scoped events are delivered exclusively to subscriptions that carry a matching `channel_id` — global subscriptions are explicitly excluded from channel fan-out as a security boundary.
|
||||
|
||||
### NIP-01 Edge Cases
|
||||
|
||||
- `kinds: []` (explicit empty array) means "match nothing" — NOT a wildcard. Subscriptions with empty `kinds` are not indexed in either tier 1 or tier 2 and never receive events.
|
||||
- `kinds` absent (no field) means "match all kinds" — indexed in tier 2 (channel wildcard) or tier 3 (global).
|
||||
|
||||
### REQ Handler Access Control
|
||||
|
||||
The REQ handler checks channel access **before** registering the subscription:
|
||||
|
||||
```
|
||||
1. Parse filters, extract channel_id
|
||||
2. Load accessible_channel_ids for this connection's pubkey
|
||||
3. If channel_id not in accessible_channels → send CLOSED "restricted: not a channel member"
|
||||
4. Only then: sub_registry.register(conn_id, sub_id, filters, channel_id)
|
||||
```
|
||||
|
||||
This prevents a race where a non-member receives live fan-out events from a private channel between registration and the access check.
|
||||
|
||||
### Historical Query (EOSE)
|
||||
|
||||
After registering, the REQ handler queries Postgres for stored events matching the filters (up to 500 per filter, hard cap). These are sent as `["EVENT", sub_id, event]` frames before `["EOSE", sub_id]`. New events arriving after EOSE are delivered via the fan-out path.
|
||||
|
||||
---
|
||||
|
||||
## 6. Crate Reference
|
||||
|
||||
### buzz-core — Shared Types and Verification
|
||||
|
||||
**Zero I/O.** The foundation every other crate builds on. Explicitly prohibits tokio, sqlx, redis, and axum in its `Cargo.toml`.
|
||||
|
||||
**Key types:**
|
||||
|
||||
```rust
|
||||
pub struct StoredEvent {
|
||||
pub event: nostr::Event,
|
||||
pub received_at: DateTime<Utc>,
|
||||
pub channel_id: Option<Uuid>,
|
||||
verified: bool, // private — use is_verified()
|
||||
}
|
||||
|
||||
pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
|
||||
```
|
||||
|
||||
**Key functions:**
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. |
|
||||
| `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. |
|
||||
| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. |
|
||||
|
||||
**Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime.
|
||||
|
||||
---
|
||||
|
||||
### buzz-auth — Authentication and Authorization
|
||||
|
||||
Handles authentication paths, scope enforcement, and token operations.
|
||||
|
||||
**Auth paths:**
|
||||
|
||||
| Path | Entry Point | Notes |
|
||||
|------|-------------|-------|
|
||||
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
|
||||
| NIP-98 HTTP Auth | `validate_nip98_auth()` | HTTP bridge endpoints; Schnorr-signed `kind:27235` event |
|
||||
|
||||
**Key types:**
|
||||
|
||||
```rust
|
||||
pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec<Scope>, pub auth_method: AuthMethod }
|
||||
pub enum AuthMethod { Nip42, Nip98 }
|
||||
pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite,
|
||||
AdminChannels, UsersRead, UsersWrite, AdminUsers,
|
||||
JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite,
|
||||
FilesRead, FilesWrite, Unknown(String) }
|
||||
pub trait ChannelAccessChecker: Send + Sync { ... }
|
||||
pub trait RateLimiter: Send + Sync { ... }
|
||||
```
|
||||
|
||||
**Security details:**
|
||||
- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags.
|
||||
- NIP-42 timestamp tolerance: ±60 seconds.
|
||||
- Dev-only key derivation: `SHA-256("buzz-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments.
|
||||
|
||||
**Does NOT:** implement `RateLimiter` beyond a test stub (`AlwaysAllowRateLimiter`, gated behind `#[cfg(any(test, feature = "test-utils"))]`). No Redis-backed rate limiter exists anywhere in the codebase — rate limiting is not currently enforced. `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) as a design target.
|
||||
|
||||
---
|
||||
|
||||
### buzz-db — Postgres Event Store
|
||||
|
||||
All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — no `.sqlx/` offline cache required.
|
||||
|
||||
**Key operations:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| `event.rs` | `insert_event` (ON CONFLICT DO NOTHING), `query_events` (QueryBuilder), `get_event_by_id` |
|
||||
| `channel.rs` | Channel CRUD, membership management, role enforcement (transactional) |
|
||||
| `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` |
|
||||
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
|
||||
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
|
||||
| `dm.rs` | DM channel management |
|
||||
| `reaction.rs` | Reaction storage and retrieval |
|
||||
| `thread.rs` | Thread/reply tracking |
|
||||
| `user.rs` | User profile storage |
|
||||
| `error.rs` | Database error types |
|
||||
|
||||
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`
|
||||
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
|
||||
**Workflow statuses:** `Active`, `Disabled`, `Archived`
|
||||
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`
|
||||
|
||||
**Key behaviors:**
|
||||
- `ON CONFLICT DO NOTHING` for event dedup — returns `(StoredEvent, was_inserted: bool)`.
|
||||
- Rejects `KIND_AUTH` (22242) and ephemeral (20000–29999) with distinct error variants.
|
||||
- Transactional role enforcement in `add_member`/`remove_member`/`create_channel` — TOCTOU-safe.
|
||||
- Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it.
|
||||
- Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit.
|
||||
- `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`.
|
||||
- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256.
|
||||
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.
|
||||
|
||||
**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.
|
||||
|
||||
---
|
||||
|
||||
### buzz-pubsub — Redis Pub/Sub, Presence, Typing
|
||||
|
||||
Manages Redis pub/sub fan-out, presence tracking, and typing indicators. In multi-community mode all tenant-visible keys are prefixed or otherwise partitioned by community (`buzz:{community}:...`) so channel fan-out, presence, typing, and cache invalidation cannot cross hosts.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Publisher → pool connection → PUBLISH buzz:channel:{uuid}
|
||||
Subscriber → dedicated PubSub → PSUBSCRIBE buzz:channel:*
|
||||
→ broadcast::channel(4096)
|
||||
```
|
||||
|
||||
The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from the pool. This is intentional: pool connections cannot hold `PSUBSCRIBE` state.
|
||||
|
||||
**Current state:** The subscriber loop is spawned in `buzz-relay/src/main.rs` and populates the broadcast channel. A consumer task subscribes via `pubsub.subscribe_local()`, calls `sub_registry.fan_out()` on each received event, and delivers matches to local WebSocket connections via `conn_manager.send_to()`. Multi-node fan-out is now wired end-to-end. Local-echo deduplication is implemented via `AppState.local_event_ids` — events published by the local relay instance are tracked and skipped when received via the Redis round-trip.
|
||||
|
||||
**Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt.
|
||||
|
||||
**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap.
|
||||
|
||||
**Typing indicators:**
|
||||
```
|
||||
ZADD buzz:typing:{channel_id} {now_unix} {pubkey_hex}
|
||||
ZREMRANGEBYSCORE buzz:typing:{channel_id} -inf {now - 5.0}
|
||||
EXPIRE buzz:typing:{channel_id} 60
|
||||
```
|
||||
5-second activity window. 60-second key TTL prevents orphaned empty sets.
|
||||
|
||||
**Does NOT:** implement the rate limiter. Does NOT store events. `PubSubManager` is not `Clone` — callers use `Arc<PubSubManager>`.
|
||||
|
||||
---
|
||||
|
||||
### buzz-search — Postgres FTS Integration
|
||||
|
||||
Full-text search via Postgres FTS. Events are searchable through the
|
||||
`events.search_tsv` generated `tsvector` column (populated on insert, indexed
|
||||
by a GIN index) — there is no separate search service or out-of-band indexer.
|
||||
Privacy-sensitive kinds are excluded at the storage level (the `search_tsv`
|
||||
`CASE WHEN kind IN (...)` yields `NULL`, which never matches `@@`). In
|
||||
multi-community mode every query filter includes `community_id`, so the shared
|
||||
`events` table is infrastructure, not a cross-community result space; the relay
|
||||
re-authorizes every candidate hit before returning it.
|
||||
|
||||
**Key behaviors:**
|
||||
- `SearchService::new(pool)` wraps a `PgPool`; `search(&SearchQuery)` runs a
|
||||
parameterized FTS query against the `events.search_tsv` GIN index and returns
|
||||
`SearchResult` (candidate `SearchHit`s).
|
||||
- `ChannelScope` makes the channel constraint explicit (`Any` /
|
||||
`ChannelLessOnly` / `Channels` / `ChannelsOrChannelLess`), closing the
|
||||
ambiguity the old `Option<Vec<Uuid>> + bool` matrix could not express.
|
||||
- Every query carries `community_id`; the FTS predicate is BitmapAnd-ed with
|
||||
the community-leading btree filters so a query never crosses tenants.
|
||||
- Permission filtering is **caller's responsibility** — `buzz-search` returns
|
||||
candidate hits; the relay re-authorizes each one (channel membership, `#p`,
|
||||
owner gates) before delivering it.
|
||||
|
||||
**Does NOT:** enforce channel membership or access control. Does NOT write
|
||||
events (indexing is the `search_tsv` generated column on the `events` insert).
|
||||
|
||||
---
|
||||
|
||||
### buzz-audit — Hash-Chain Audit Log
|
||||
|
||||
Tamper-evident append-only log with SHA-256 hash chaining.
|
||||
|
||||
**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). In multi-community mode audit heads/chains are per-community; operator metrics may aggregate, but tenant-readable audit verification walks one community chain. `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros).
|
||||
|
||||
**Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash.
|
||||
|
||||
**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`).
|
||||
|
||||
**10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`.
|
||||
|
||||
**Does NOT:** log `KIND_AUTH` (22242) events — returns `AuditError::AuthEventForbidden` immediately. Does NOT log ephemeral events (they never reach the audit pipeline).
|
||||
|
||||
---
|
||||
|
||||
### buzz-workflow — YAML-as-Code Automation Engine
|
||||
|
||||
Parses, validates, and executes channel-scoped workflow definitions. In multi-community mode workflow definitions, runs, approvals, webhook routes, and schedules inherit the host-derived community and evaluate triggers only against events in that community.
|
||||
|
||||
**Workflow definition structure:**
|
||||
```yaml
|
||||
name: "Incident Triage"
|
||||
trigger:
|
||||
on: message_posted
|
||||
filter: "str_contains(trigger_text, 'P1')"
|
||||
steps:
|
||||
- id: notify
|
||||
action: send_message
|
||||
text: "P1 incident detected: {{trigger.text}}"
|
||||
- id: page
|
||||
if: "str_contains(trigger_text, 'production')"
|
||||
action: request_approval
|
||||
from: "{{trigger.author}}"
|
||||
message: "Page on-call?"
|
||||
```
|
||||
|
||||
Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Triggers use `on:` as the tag field; actions use `action:` as the tag field. Fields are flattened into the parent struct, not nested.
|
||||
|
||||
**4 trigger types:** `message_posted`, `reaction_added`, `schedule`, `webhook`
|
||||
|
||||
**7 action types:**
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `send_message` | Post to the workflow's channel (or override channel) |
|
||||
| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) |
|
||||
| `set_channel_topic` | Update channel topic |
|
||||
| `add_reaction` | React to the trigger message |
|
||||
| `call_webhook` | HTTP POST to external URL (SSRF-protected, redirects disabled, 1 MiB response cap) |
|
||||
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
|
||||
| `delay` | Pause execution (max 300 seconds) |
|
||||
|
||||
**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.
|
||||
|
||||
**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.
|
||||
|
||||
**Concurrency:** `Arc<Semaphore>` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing.
|
||||
|
||||
**Approval gates:** `request_approval` action returns `StepResult::Suspended` with a generated UUID token, but the engine does not yet persist the token or resume execution — runs that hit an approval gate are marked as failed (🚧 WF-08). `execute_from_step()` exists for future resumption support.
|
||||
|
||||
**Cron scheduler:** loop ticks every 60 seconds, evaluates cron expressions with window-based matching, and creates workflow runs for matched triggers. Fully implemented.
|
||||
|
||||
**Does NOT:** recursively resolve templates (single-pass only). Does NOT queue workflow runs when at capacity — returns `CapacityExceeded` immediately.
|
||||
|
||||
---
|
||||
|
||||
### Huddle Audio — WebSocket Opus Relay
|
||||
|
||||
Real-time voice lives inside `buzz-relay` (`src/audio/`), not a separate crate. A WebSocket endpoint (`wss://.../huddle/{channel_id}/audio`) authenticates each participant with a NIP-42 challenge, checks channel membership, admits them to an in-memory room, and forwards opaque Opus frames between peers. No external SFU.
|
||||
|
||||
**Frame protocol (v2):** 8-byte big-endian header (sequence `u16`, 48 kHz timestamp `u32`, level dBov `i8`, flags `u8`) followed by an opaque Opus payload. Invalid `level_dbov` values are clamped rather than dropped — losing a metric beats losing audio.
|
||||
|
||||
**Room state:** an admission guard synchronizes joins against the room's ended flag; soft cap 25 peers (hard cap 255 via `u8` peer index). Per-peer audio uses a bounded channel (drop-on-full); the control channel is separate and never drops join/leave.
|
||||
|
||||
**Lifecycle events:** the relay emits Nostr events for participant joined / left and huddle ended; the desktop client emits huddle started and guidelines. When the last peer leaves, the room ends and the channel archives atomically.
|
||||
|
||||
**Not yet built:** recording and per-track publishing (the corresponding kinds are reserved, no producer exists).
|
||||
|
||||
---
|
||||
|
||||
### buzz-relay — The Server
|
||||
|
||||
Axum WebSocket server. Ties all other crates together. The only crate that imports and orchestrates all subsystems.
|
||||
|
||||
**`AppState`** (Arc-wrapped, shared across all connections — key fields shown, not exhaustive):
|
||||
|
||||
```rust
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
pub audit: Arc<AuditService>,
|
||||
pub pubsub: Arc<PubSubManager>,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub search: Arc<SearchService>,
|
||||
pub sub_registry: Arc<SubscriptionRegistry>,
|
||||
pub conn_manager: Arc<ConnectionManager>,
|
||||
pub workflow_engine: Arc<WorkflowEngine>,
|
||||
pub conn_semaphore: Arc<Semaphore>, // connection limit
|
||||
pub handler_semaphore: Arc<Semaphore>, // 1024 concurrent handlers
|
||||
pub relay_keypair: nostr::Keys, // relay identity
|
||||
pub local_event_ids: moka::sync::Cache, // local-echo dedup
|
||||
pub search_index_tx: mpsc::Sender, // bounded search worker queue
|
||||
// + config, redis_pool, membership_cache, media_storage, shutdown state
|
||||
}
|
||||
```
|
||||
|
||||
**`ConnectionState`** (per-connection):
|
||||
|
||||
```rust
|
||||
pub struct ConnectionState {
|
||||
pub auth_state: RwLock<AuthState>,
|
||||
pub subscriptions: Mutex<HashMap<String, Vec<Filter>>>,
|
||||
// + send_tx, cancel token
|
||||
}
|
||||
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
|
||||
```
|
||||
|
||||
**HTTP endpoints:**
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/` | WebSocket upgrade or NIP-11 relay info |
|
||||
| GET | `/info` | NIP-11 relay info |
|
||||
| GET | `/.well-known/nostr.json` | NIP-05 identity |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/_liveness` | Liveness probe |
|
||||
| GET | `/_readiness` | Readiness probe |
|
||||
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
|
||||
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
|
||||
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
|
||||
| POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) |
|
||||
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
|
||||
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
|
||||
| GET | `/git/{owner}/{repo}/info/refs` | Git smart HTTP advertisement |
|
||||
| POST | `/git/{owner}/{repo}/git-upload-pack` | Git smart HTTP fetch |
|
||||
| POST | `/git/{owner}/{repo}/git-receive-pack` | Git smart HTTP push |
|
||||
| POST | `/internal/git/policy` | Internal git hook policy check |
|
||||
|
||||
**Constants:**
|
||||
|
||||
| Constant | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `MAX_FRAME_BYTES` | 65,536 | Max WebSocket frame size |
|
||||
| `MAX_SUBSCRIPTIONS` | 1024 | Per-connection subscription limit |
|
||||
| `MAX_HISTORICAL_LIMIT` | 500 | Per-filter historical query cap |
|
||||
| `handler_semaphore` capacity | 1024 | Concurrent EVENT/REQ handlers |
|
||||
|
||||
**Does NOT:** implement business logic — delegates to the appropriate crate for every operation.
|
||||
|
||||
---
|
||||
|
||||
### buzz-acp — Agent Communication Protocol Harness
|
||||
|
||||
Standalone binary that bridges Buzz relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP).
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Buzz Relay ──WS──→ buzz-acp ──stdio (ACP/JSON-RPC)──→ Agent (goose/codex/claude)
|
||||
```
|
||||
|
||||
`buzz-acp` spawns AI agent subprocesses (1–32, default 1), connects to the relay via WebSocket with NIP-42 auth, discovers channels via REST API, and queues `@mention` events per channel. At most one prompt is in-flight per channel. Queued events are batched into a single prompt sent via `session/prompt` over ACP.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | LOC | Responsibility |
|
||||
|--------|-----|---------------|
|
||||
| `relay.rs` | 3,143 | WebSocket + REST relay connection, NIP-42 auth |
|
||||
| `queue.rs` | 2,565 | Per-channel event queue, batching, dedup |
|
||||
| `main.rs` | 2,457 | Event loop, pool orchestration, heartbeat |
|
||||
| `pool.rs` | 2,253 | N-agent pool, claim/return lifecycle |
|
||||
| `config.rs` | 1,903 | CLI/env/TOML configuration |
|
||||
| `acp.rs` | 1,785 | ACP client, stdio JSON-RPC, timeouts |
|
||||
| `filter.rs` | 814 | Subscription rules, evalexpr filtering |
|
||||
|
||||
**Key behaviors:**
|
||||
- Pool of 1–32 agent subprocesses with claim/return lifecycle.
|
||||
- Per-channel queuing: at most one prompt in-flight per channel; subsequent @mentions queue until the agent responds.
|
||||
- Crash recovery: agent subprocess crashes are detected and the agent is respawned.
|
||||
- Depends on `buzz-core` (kind constants) and `buzz-sdk` (relay/REST utilities).
|
||||
|
||||
**Does NOT:** persist state.
|
||||
|
||||
---
|
||||
|
||||
### buzz-admin — Operator CLI
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Purpose |
|
||||
|------------|---------|
|
||||
| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`); accepts npub or hex; publishes kind:13534 roster |
|
||||
| `remove-member` | Remove a pubkey from the relay membership list (`--pubkey`, optional `--role` guard); publishes kind:13534 roster |
|
||||
| `list-members` | List all relay members |
|
||||
| `generate-key` | Generate a new Nostr keypair (for bootstrapping) |
|
||||
| `reconcile-channels` | Emit kind:39000/39002 discovery events for channels missing them (idempotent) |
|
||||
|
||||
The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/buzz-admin`) and is the recommended way to manage relay membership in production. Use `./run.sh add-member`, `./run.sh remove-member`, and `./run.sh list-members` in Docker Compose deployments.
|
||||
|
||||
---
|
||||
|
||||
### buzz-test-client — Integration Test Harness
|
||||
|
||||
**`BuzzTestClient`** wraps a WebSocket connection with a `VecDeque<RelayMessage>` buffer for message interleaving. Methods: `connect`, `connect_unauthenticated`, `authenticate`, `send_event`, `send_text_message`, `subscribe`, `close_subscription`, `recv_event`, `collect_until_eose`, `disconnect`.
|
||||
|
||||
**Test coverage:**
|
||||
|
||||
| File | Tests | Scope |
|
||||
|------|-------|-------|
|
||||
| `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
|
||||
| `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
|
||||
| `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
|
||||
| `tests/e2e_nostr_interop.rs` | 15 | Nostr interoperability: NIP-50 search, NIP-10 threads, NIP-17 gift wraps, DM discovery |
|
||||
|
||||
All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e tests**.
|
||||
|
||||
`src/main.rs` is a manual testing CLI (`buzz-test-cli`) with `--send`, `--subscribe`, `--channel`, `--url`, `--kind` flags.
|
||||
|
||||
Defines `parse_relay_message`, `OkResponse`, `RelayMessage` directly in `src/lib.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Model
|
||||
|
||||
Every security-sensitive operation uses an explicit, verified pattern. No implicit trust.
|
||||
|
||||
### Authentication
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
|
||||
| AUTH events | Never stored in Postgres, never logged in audit chain |
|
||||
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification |
|
||||
|
||||
### Input Validation
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| Schnorr signatures | `verify_event()` in `buzz-core` — every event verified before storage |
|
||||
| Event ID | SHA-256 of canonical serialization verified independently of signature |
|
||||
| Frame size | `MAX_FRAME_BYTES = 65,536` — oversized frames rejected, connection closed |
|
||||
| Search event IDs | 64-char hex validation before URL construction — prevents path injection |
|
||||
| Workflow step IDs | Alphanumeric + underscore only — prevents evalexpr variable injection |
|
||||
| Partition names | Allowlist of table names + strict suffix/date validators — prevents DDL injection |
|
||||
|
||||
### SSRF Protection
|
||||
|
||||
`is_private_ip()` in `buzz-core` covers:
|
||||
- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255)
|
||||
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32)
|
||||
- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address
|
||||
|
||||
Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility).
|
||||
|
||||
### Audit Integrity
|
||||
|
||||
- Hash chain: each entry's SHA-256 covers all fields including `prev_hash` — tampering any entry breaks all subsequent hashes
|
||||
- Canonical JSON: `BTreeMap` for deterministic key ordering — hash is reproducible
|
||||
- Single-writer lock: `pg_advisory_lock` — prevents concurrent writes from breaking the chain
|
||||
- Panic-safe: `catch_unwind` ensures lock release even on panic
|
||||
|
||||
### Access Control
|
||||
|
||||
- Channel membership is the only gate — enforced by the relay at every operation
|
||||
- REQ handler checks access before subscription registration — no race window for private channel leaks
|
||||
- TOCTOU-safe membership operations: all check-then-modify sequences run inside Postgres transactions
|
||||
- Approval tokens: UUID (CSPRNG), stored as SHA-256 hash, single-use enforced with `AND status = 'pending'` in UPDATE
|
||||
|
||||
### Webhook Security
|
||||
|
||||
- Workflow webhooks: constant-time XOR comparison of stored UUID secret (not HMAC — compares the secret directly, not a body MAC)
|
||||
- Outbound webhooks (CallWebhook): SSRF protection + redirects disabled + 1 MiB response cap
|
||||
|
||||
---
|
||||
|
||||
## 8. Infrastructure
|
||||
|
||||
Docker Compose provides the full local development stack. All services include health checks and resource limits.
|
||||
|
||||
### Services
|
||||
|
||||
| Service | Image | Port | Purpose |
|
||||
|---------|-------|------|---------|
|
||||
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit; full-text search (`search_tsv` GIN) |
|
||||
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
|
||||
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
|
||||
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
|
||||
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
|
||||
|
||||
### Postgres Schema (key tables)
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `events` | All stored Nostr events; monthly range-partitioned by `PARTITION BY RANGE` on `created_at`; multi-community mode keys every tenant-visible event by `community_id` |
|
||||
| `channels` | Channel records (type, visibility, canvas, topic); `community_id` is immutable after creation in multi-community mode |
|
||||
| `channel_members` | Membership with roles; soft-delete via `removed_at` |
|
||||
| `workflows` | Workflow definitions (YAML stored as canonical JSON); scoped by community in multi-community mode |
|
||||
| `workflow_runs` | Execution records with trigger context and trace |
|
||||
| `workflow_approvals` | Approval gates (token stored as SHA-256 hash) |
|
||||
| `audit_log` | Hash-chain audit entries; per-community chain/head in multi-community mode |
|
||||
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
|
||||
|
||||
### Redis Key Patterns
|
||||
|
||||
| Pattern | Type | TTL | Purpose |
|
||||
|---------|------|-----|---------|
|
||||
| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) |
|
||||
| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
|
||||
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
|
||||
|
||||
### Full-Text Search (Postgres FTS)
|
||||
|
||||
Search runs over the `events.search_tsv` generated `tsvector` column on the
|
||||
`events` table (no separate collection or service). The column is populated on
|
||||
insert — `to_tsvector('simple', content)` — and excludes privacy-sensitive
|
||||
kinds via `CASE WHEN kind IN (1059, 30300, 30622) THEN NULL`, so those rows are
|
||||
storage-level unsearchable (a `NULL` tsvector never matches `@@`). A GIN index
|
||||
(`idx_events_search_tsv`) backs the `@@` probe; in multi-community mode the
|
||||
community-leading btree filters BitmapAnd with the GIN probe so every query is
|
||||
fenced to its `community_id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known Limitations
|
||||
|
||||
These are verified gaps in the current implementation — not design aspirations.
|
||||
|
||||
| # | Limitation | Detail |
|
||||
|---|-----------|--------|
|
||||
| 1 | **No sqlx offline query cache** | Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). No `.sqlx/` directory. Queries are not validated at compile time. |
|
||||
| 2 | **No rate limiting implementation** | `RateLimiter` trait exists in `buzz-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. |
|
||||
| 3 | **No dedicated typing REST endpoint** | Typing indicators (kind 20002) are delivered via both local fan-out and Redis pub/sub (cross-node). There is no REST endpoint to query current typers — `/api/presence` returns online/away status only, not typing state. |
|
||||
| 4 | **Huddle recording/tracks not built** | Voice, room lifecycle, and join/leave/end events are wired (see Huddle Audio above). Recording and per-track publishing have reserved kinds but no producer yet. |
|
||||
| 5 | **Approval gates not wired end-to-end** | The executor returns `StepResult::Suspended` and the relay has grant/deny API endpoints with DB CRUD, but the engine intercepts before creating `WaitingApproval` rows — runs that hit an approval gate are marked as Failed (🚧 WF-08). |
|
||||
| 6 | **Workflow actions partially stubbed** | The `send_dm` and `set_channel_topic` workflow actions are in the schema but return `NotImplemented` — a run that reaches one fails at execution (🚧 WF-07). |
|
||||
962
CHANGELOG.md
962
CHANGELOG.md
@@ -1,962 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Polish agent and team sharing and snapshots ([#1852](https://github.com/block/buzz/pull/1852)) ([`2f3fa586f`](https://github.com/block/buzz/commit/2f3fa586f3bd12b26b8a9dc69a424d5c28b1e27e))
|
||||
- fix(desktop): filter punctuation from avatar initials ([#1904](https://github.com/block/buzz/pull/1904)) ([`63e099709`](https://github.com/block/buzz/commit/63e099709667669be41426e7144a4a9057f8537b))
|
||||
- fix(desktop): preserve snapshot attachment URLs ([#1905](https://github.com/block/buzz/pull/1905)) ([`1ab7bbfe4`](https://github.com/block/buzz/commit/1ab7bbfe453d10cfd3522c358e5ed18931f1e1ec))
|
||||
- Chat-first agent creation via portable buzz CLI drafts ([#1878](https://github.com/block/buzz/pull/1878)) ([`54e174b5e`](https://github.com/block/buzz/commit/54e174b5e1424c13fe7451c86a418ba8b9c01f1c))
|
||||
- feat(desktop): enable community rail by default ([#1902](https://github.com/block/buzz/pull/1902)) ([`55a46b063`](https://github.com/block/buzz/commit/55a46b063e291862c034d4ce685b3d3a92804951))
|
||||
- feat(desktop): redesign Nostr bind verification flow ([#1850](https://github.com/block/buzz/pull/1850)) ([`abfe78aaf`](https://github.com/block/buzz/commit/abfe78aafb131c9100f33ed361850fb9cfeb0881))
|
||||
- fix(desktop): simplify sign out settings row ([#1903](https://github.com/block/buzz/pull/1903)) ([`3e8dae7ff`](https://github.com/block/buzz/commit/3e8dae7ff3c73b1b2b1ae26d5a4bed5db17cb269))
|
||||
- feat(desktop): Send feedback modal + profile presence chip ([#1756](https://github.com/block/buzz/pull/1756)) ([`f5c33d333`](https://github.com/block/buzz/commit/f5c33d3335fc3fa7e304bb51a9931d4cc741d255))
|
||||
- fix(desktop): seed timeline virtualization row heights ([#1887](https://github.com/block/buzz/pull/1887)) ([`406cf7911`](https://github.com/block/buzz/commit/406cf7911ee1f335fdf0be131ee057ea2c455037))
|
||||
- fix(desktop): dedupe react-dismissable-layer to stop modal menu → dialog freezes ([#1899](https://github.com/block/buzz/pull/1899)) ([`366567ac2`](https://github.com/block/buzz/commit/366567ac20b4786c1ca8e754ac5c45f0e7d59122))
|
||||
- fix(desktop): clean up Remind me later dialog footer and loading state ([#1898](https://github.com/block/buzz/pull/1898)) ([`40fe65095`](https://github.com/block/buzz/commit/40fe6509583e93054eab04906aacde834c5fcc68))
|
||||
- fix(desktop): let sidebar action card description wrap over multiple lines ([#1891](https://github.com/block/buzz/pull/1891)) ([`b36ee3e30`](https://github.com/block/buzz/commit/b36ee3e30203b4cc4ec8056ae4d9c57acf26891e))
|
||||
- mesh: upgrade runtime, enforce membership, add shared compute provider ([#1656](https://github.com/block/buzz/pull/1656)) ([`54638ff4b`](https://github.com/block/buzz/commit/54638ff4bb5af2d3d3759b44118b43052f814bb1))
|
||||
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- test(desktop): remove stale collapsed home chrome check ([#1871](https://github.com/block/buzz/pull/1871)) ([`395a3f149`](https://github.com/block/buzz/commit/395a3f1492807b065f8edbee9bb9c8d51018095a))
|
||||
- fix(desktop): hide message action for inaccessible agents ([#1883](https://github.com/block/buzz/pull/1883)) ([`8ce9e7914`](https://github.com/block/buzz/commit/8ce9e791453a262f21e675d3f5d27ace22a09e9b))
|
||||
- feat(desktop): add API key field, global default indicators, and collapsed advanced ([#1875](https://github.com/block/buzz/pull/1875)) ([`1742dce7b`](https://github.com/block/buzz/commit/1742dce7b25676e91631ce4056bdd2bbc05944ab))
|
||||
- fix(desktop): foreground app for deep links ([#1880](https://github.com/block/buzz/pull/1880)) ([`a26168f55`](https://github.com/block/buzz/commit/a26168f553fd8bfce8b5df88915e76ff2d7004ee))
|
||||
- chore(desktop): replace setup ? operators with clean exit ([#1882](https://github.com/block/buzz/pull/1882)) ([`4c106d878`](https://github.com/block/buzz/commit/4c106d878564e3f5b4def92f4667fe95dec015f4))
|
||||
- fix(desktop): remediate all onboarding dead paths (D1-D7) ([#1856](https://github.com/block/buzz/pull/1856)) ([`5bb619295`](https://github.com/block/buzz/commit/5bb6192951bd57c535a34cd835aba20714f408b6))
|
||||
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- fix(desktop): allow staged updater commands ([#1870](https://github.com/block/buzz/pull/1870)) ([`109c2c526`](https://github.com/block/buzz/commit/109c2c52641c5edd50890bffef9a236a10ea6106))
|
||||
- feat(teams): unify team model, snapshot sharing, and PNG memory parity ([#1846](https://github.com/block/buzz/pull/1846)) ([`448baeef7`](https://github.com/block/buzz/commit/448baeef770478411678e2f0be1729f04b6800d7))
|
||||
- fix(desktop): always start agents and hide unavailable mesh ([#1860](https://github.com/block/buzz/pull/1860)) ([`37bc962cb`](https://github.com/block/buzz/commit/37bc962cb8fb60857337bbfb97eb4d31c359de15))
|
||||
- Relay mesh: cross-pod tunnel + huddle transport (buzz-relay-mesh) ([#1670](https://github.com/block/buzz/pull/1670)) ([`ccb021d71`](https://github.com/block/buzz/commit/ccb021d71339009aabedc383c8f3d8e5c23e1e42))
|
||||
- feat(desktop): add Sign Out to Settings to reset and relaunch ([#1842](https://github.com/block/buzz/pull/1842)) ([`cf9723796`](https://github.com/block/buzz/commit/cf97237965a5ed9870f1c1f73ce9952e963b418b))
|
||||
|
||||
|
||||
## v0.4.3
|
||||
|
||||
- Stabilize persona and stream end-to-end tests ([#1865](https://github.com/block/buzz/pull/1865)) ([`916de4e4d`](https://github.com/block/buzz/commit/916de4e4dbbf9299695a91defa6e3802b1820256))
|
||||
- refactor(clients): standardize product naming on community ([#1858](https://github.com/block/buzz/pull/1858)) ([`3e76481a1`](https://github.com/block/buzz/commit/3e76481a149bb3de459298cc989505c470c2372c))
|
||||
- fix(desktop): retain live rows after window exhaustion ([#1810](https://github.com/block/buzz/pull/1810)) ([`fa6f4819b`](https://github.com/block/buzz/commit/fa6f4819b3468a5a3725356dd71463bb37cc30c7))
|
||||
- Add private product feedback sidecar ([#1857](https://github.com/block/buzz/pull/1857)) ([`af190c93e`](https://github.com/block/buzz/commit/af190c93e1048af64c3fbfb3831c689cb703997c))
|
||||
- fix(desktop): resolve Doctor install shell and command detection on Windows ([#1854](https://github.com/block/buzz/pull/1854)) ([`1aec7ea7a`](https://github.com/block/buzz/commit/1aec7ea7a798c8bdb131a802dcd3d2ea0fb8692a))
|
||||
- fix(desktop): navigate to channel from inbox thread header ([#1847](https://github.com/block/buzz/pull/1847)) ([`ff11f26bc`](https://github.com/block/buzz/commit/ff11f26bcdc8e107d7d04f5682b2d17f586536d6))
|
||||
- feat(desktop): surface needsRestart badge on live UI surfaces ([#1853](https://github.com/block/buzz/pull/1853)) ([`b1d323c4e`](https://github.com/block/buzz/commit/b1d323c4e7ddbabbe52a868f3699a523bb6c010e))
|
||||
- fix(desktop): prevent menu-to-dialog UI lockups ([#1839](https://github.com/block/buzz/pull/1839)) ([`7f81d93eb`](https://github.com/block/buzz/commit/7f81d93eb1cf6a10c84d948d91c45a1fd4778060))
|
||||
- feat(desktop): add conversation-style DM composer ([#1768](https://github.com/block/buzz/pull/1768)) ([`bb9d9b0c0`](https://github.com/block/buzz/commit/bb9d9b0c049b9561458414b24eff157c6bbbd446))
|
||||
- feat(push): add public APNs gateway ([#1770](https://github.com/block/buzz/pull/1770)) ([`1c006822e`](https://github.com/block/buzz/commit/1c006822e4484d68e33fce14f9139c2f70ce9d66))
|
||||
- fix(desktop): stabilize agent identity restore ([#1831](https://github.com/block/buzz/pull/1831)) ([`3370cd083`](https://github.com/block/buzz/commit/3370cd0836e458f519cf90fe6299279cde6c7463))
|
||||
- fix(observer): align scroll-anchor ids with transcript display-block keys ([#1849](https://github.com/block/buzz/pull/1849)) ([`1df4a4e77`](https://github.com/block/buzz/commit/1df4a4e77da732cadad91738bc0271a926c22040))
|
||||
- [codex] Add view activity label to agent popover ([#1748](https://github.com/block/buzz/pull/1748)) ([`458c7f915`](https://github.com/block/buzz/commit/458c7f9154fc9580a766117cad0e3e378ffe5ac0))
|
||||
- ci(desktop): surface flaky E2E tests instead of retry-masking them ([#1838](https://github.com/block/buzz/pull/1838)) ([`a65340630`](https://github.com/block/buzz/commit/a653406309aa6dab8122cf3e84ddeca0c87013be))
|
||||
- fix(desktop): treat channel creator as member before 39002 provisioning ([#1830](https://github.com/block/buzz/pull/1830)) ([`7e62a25af`](https://github.com/block/buzz/commit/7e62a25af0bc71df17de806673ed02514e556e6e))
|
||||
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- fix(desktop): unify observer feed scroll onto useAnchoredScroll ([#1825](https://github.com/block/buzz/pull/1825)) ([`d75c2e913`](https://github.com/block/buzz/commit/d75c2e913eef763e8ea2e748c58353c3a22d544d))
|
||||
- fix(desktop): sync sidebar update card copy with global installing state ([#1827](https://github.com/block/buzz/pull/1827)) ([`b63a2e423`](https://github.com/block/buzz/commit/b63a2e4231d27f61eae952d2febab36489e773a5))
|
||||
- fix(desktop): parse codex ACP plan entries[] into checklist ([#1824](https://github.com/block/buzz/pull/1824)) ([`34dcd13d5`](https://github.com/block/buzz/commit/34dcd13d559b6c606312fca1bbc2c52310b35603))
|
||||
- fix(desktop): resolve Git Bash for Windows shell tool via PATH/git/registry fallback ([#1821](https://github.com/block/buzz/pull/1821)) ([`020ac7f40`](https://github.com/block/buzz/commit/020ac7f405c24f72e390b605571fb65e2481612c))
|
||||
- feat(desktop): show read-only MCP server config ([#1780](https://github.com/block/buzz/pull/1780)) ([`51ee1c473`](https://github.com/block/buzz/commit/51ee1c473dae018499e42106086c359c19108137))
|
||||
- fix(desktop): require user action before applying updates ([#1820](https://github.com/block/buzz/pull/1820)) ([`deed64a14`](https://github.com/block/buzz/commit/deed64a14f1ec39a1242bed46711e44d77a3086e))
|
||||
- fix(desktop): preserve selected inbox rows through reflow ([#1817](https://github.com/block/buzz/pull/1817)) ([`c06ddcf14`](https://github.com/block/buzz/commit/c06ddcf14b81834a59a4eedf020ccbe8ca72dfd7))
|
||||
- fix(snapshot): send Buzz shares as PNG avatar cards ([#1811](https://github.com/block/buzz/pull/1811)) ([`a6cfd6551`](https://github.com/block/buzz/commit/a6cfd65511ee06f9073f2d5405e2242a9e2ff4ef))
|
||||
- test(desktop): consolidate and stabilize scroll coverage ([#1815](https://github.com/block/buzz/pull/1815)) ([`d41b4c390`](https://github.com/block/buzz/commit/d41b4c390522d63a0a4e1b3e8a93fe9c84289b2a))
|
||||
- fix(buzz-agent): support max effort for gpt-5.6 family ([#1806](https://github.com/block/buzz/pull/1806)) ([`e34e0974a`](https://github.com/block/buzz/commit/e34e0974a149a2b776e779e5798cc4252cc5030c))
|
||||
- fix(desktop): widen probe test timing margins for parallel pre-push ([#1812](https://github.com/block/buzz/pull/1812)) ([`fac79215a`](https://github.com/block/buzz/commit/fac79215a4cdc8289cc94282641e69d822d419d6))
|
||||
- fix(desktop): keep pairing tests after production items ([#1807](https://github.com/block/buzz/pull/1807)) ([`68909629f`](https://github.com/block/buzz/commit/68909629f96a95797a627b19e76e03bcbd6983de))
|
||||
- chore(deps): update rust crate nostr to v0.44.4 ([#1789](https://github.com/block/buzz/pull/1789)) ([`f1f5002a9`](https://github.com/block/buzz/commit/f1f5002a970bb651173ef49027e9609b903e3fa3))
|
||||
- Add optional standalone pairing relay to Helm chart ([#1799](https://github.com/block/buzz/pull/1799)) ([`9b47c8548`](https://github.com/block/buzz/commit/9b47c8548fd061fbb806ea8b9ddee831c19cf80e))
|
||||
- chore(ci): lint desktop Tauri crate in CI ([#1801](https://github.com/block/buzz/pull/1801)) ([`bea507d8a`](https://github.com/block/buzz/commit/bea507d8aa611d8d0be893bac1339dad50162d7b))
|
||||
- fix(desktop): preserve live events and window order across channel refreshes ([#1802](https://github.com/block/buzz/pull/1802)) ([`e09e94ab8`](https://github.com/block/buzz/commit/e09e94ab88543ea0e0c6411092852c1c98187feb))
|
||||
- fix(desktop): restore macOS navigation chrome alignment ([#1797](https://github.com/block/buzz/pull/1797)) ([`109800fe5`](https://github.com/block/buzz/commit/109800fe5a67c560e3ffb750e7df28177c03f509))
|
||||
- fix(desktop): omit invalid snapshot imeta thumbnails ([#1800](https://github.com/block/buzz/pull/1800)) ([`b32f2c08f`](https://github.com/block/buzz/commit/b32f2c08f7cdbad1bcc3b6a73711c2e7a21c71bd))
|
||||
- fix(desktop): centralize known-agent trust set in useKnownAgentPubkeys ([#1703](https://github.com/block/buzz/pull/1703)) ([`095f3c728`](https://github.com/block/buzz/commit/095f3c7280153394bf2a8215b1355ef734af9ee6))
|
||||
- chore(deps): update all non-major dependencies ([#1778](https://github.com/block/buzz/pull/1778)) ([`221aceb1c`](https://github.com/block/buzz/commit/221aceb1c2f794db0bf2050d1cc66fe8d2db9cec))
|
||||
- fix(desktop): pass augmented PATH to codex-acp version probe ([#1794](https://github.com/block/buzz/pull/1794)) ([`10ae66893`](https://github.com/block/buzz/commit/10ae66893c8b454255298d0eab40bfd47a46041c))
|
||||
- feat(desktop): add team snapshot sharing commands ([#1790](https://github.com/block/buzz/pull/1790)) ([`150514f66`](https://github.com/block/buzz/commit/150514f665c5aeebb83f9c42c17e793d88ee6b9f))
|
||||
- feat(managed_agents): add buzz-team-snapshot v1 codec ([#1784](https://github.com/block/buzz/pull/1784)) ([`aceb5e045`](https://github.com/block/buzz/commit/aceb5e0456c19761f1210585be520fa291ef903c))
|
||||
|
||||
|
||||
## v0.4.1
|
||||
|
||||
- feat(desktop): auto-restart setup-mode agents after adapter install, badge drift fallback ([#1786](https://github.com/block/buzz/pull/1786)) ([`11a286b9a`](https://github.com/block/buzz/commit/11a286b9a4284f4f80943d1321f86fa41c472dcf))
|
||||
- Retain a stable message window in channel timelines ([#1698](https://github.com/block/buzz/pull/1698)) ([`b0ad2b66d`](https://github.com/block/buzz/commit/b0ad2b66ddb2d91bcee1473050d21f27e2a38eac))
|
||||
- refactor(desktop): remove legacy persona-card flows ([#1781](https://github.com/block/buzz/pull/1781)) ([`1fa91f569`](https://github.com/block/buzz/commit/1fa91f569114cfa1d22951311fe80cef0def36d7))
|
||||
- Serialize managed agent PATH tests ([#1777](https://github.com/block/buzz/pull/1777)) ([`1fc7488ea`](https://github.com/block/buzz/commit/1fc7488ea58accf8bd96afe94cd93ee54174df00))
|
||||
- fix(desktop): stable inbox selection and scroll/draft preservation ([#1760](https://github.com/block/buzz/pull/1760)) ([`7e3252ea3`](https://github.com/block/buzz/commit/7e3252ea3e6b5c6c0db2e2a36c839d9cc2700c89))
|
||||
- feat(desktop): add buzz-agent-snapshot v1 export, import, sender-native send, and recipient card/import ([#1753](https://github.com/block/buzz/pull/1753)) ([`1580046b3`](https://github.com/block/buzz/commit/1580046b3c2c3d208e84fb240467454b8f3813f0))
|
||||
- refactor(desktop): remove vestigial MCP toolsets config ([#1776](https://github.com/block/buzz/pull/1776)) ([`dfec75b3c`](https://github.com/block/buzz/commit/dfec75b3c0b8080529e4d9089d4ed80e3902aaed))
|
||||
- feat(desktop): add key backup and agent defaults to onboarding, make avatar optional ([#1767](https://github.com/block/buzz/pull/1767)) ([`d2e87e1ca`](https://github.com/block/buzz/commit/d2e87e1ca71368312576b7f492818a50042e61e1))
|
||||
- fix(desktop): correct provider and model handling in agent config dialogs ([#1764](https://github.com/block/buzz/pull/1764)) ([`f3319dd11`](https://github.com/block/buzz/commit/f3319dd11102bfd52e1ec3065a4d9272a03075ec))
|
||||
- fix(desktop): cascade persona deletes and restart agents on global config save ([#1766](https://github.com/block/buzz/pull/1766)) ([`7c346d7f8`](https://github.com/block/buzz/commit/7c346d7f85e7fcb64999b502306f7895d37fb42a))
|
||||
- fix(desktop): make doctor installs retryable with per-runtime progress and auth status ([#1765](https://github.com/block/buzz/pull/1765)) ([`25bb71475`](https://github.com/block/buzz/commit/25bb7147523f27a88f1bfece0310889442699c5f))
|
||||
- fix(codex): swap to @agentclientprotocol/codex-acp 1.x + detect outdated adapter ([#1750](https://github.com/block/buzz/pull/1750)) ([`6d1a77e49`](https://github.com/block/buzz/commit/6d1a77e49255f18434593c9c7daa811f5c76db0b))
|
||||
- fix(desktop): show thread replies loader ([#1773](https://github.com/block/buzz/pull/1773)) ([`e07e02c0e`](https://github.com/block/buzz/commit/e07e02c0eb76376b96e0a9d5fe27009111430845))
|
||||
- fix(desktop): restore multi-image mosaic galleries ([#1769](https://github.com/block/buzz/pull/1769)) ([`2c41e9e6b`](https://github.com/block/buzz/commit/2c41e9e6b836fad57da981ec9329fe51e5796085))
|
||||
- Group channel membership events ([#1713](https://github.com/block/buzz/pull/1713)) ([`5dc70bd0b`](https://github.com/block/buzz/commit/5dc70bd0b8561ce8aa24338903167e62d6035d10))
|
||||
- Fix desktop launch motion and reaction spacing ([#1717](https://github.com/block/buzz/pull/1717)) ([`89e2e2e4a`](https://github.com/block/buzz/commit/89e2e2e4adf4d062995aed46b32f4935c6992513))
|
||||
- fix(desktop): align sidebar search across themes ([#1712](https://github.com/block/buzz/pull/1712)) ([`53c177e34`](https://github.com/block/buzz/commit/53c177e341d4f9e7b254b7c83af39380e9b10242))
|
||||
- fix(desktop): Buzz theme flicker, white bar & accent-picker motion ([#1681](https://github.com/block/buzz/pull/1681)) ([`5e15a588e`](https://github.com/block/buzz/commit/5e15a588e072198b922fb3793a48d9c98146fc45))
|
||||
- feat(desktop): surface Team Instructions as distinct observer section ([#1759](https://github.com/block/buzz/pull/1759)) ([`56d822dc2`](https://github.com/block/buzz/commit/56d822dc2c9b78df50fad7928a8d088b7618e671))
|
||||
- fix(desktop): separate system prompt from turn context ([#1754](https://github.com/block/buzz/pull/1754)) ([`84ec63aa2`](https://github.com/block/buzz/commit/84ec63aa24d14553206dde5dbfc61807755313b8))
|
||||
- fix(desktop): preserve archived observer history ([#1752](https://github.com/block/buzz/pull/1752)) ([`51234bd89`](https://github.com/block/buzz/commit/51234bd89f1dffcd82bae45428094cff35c4081e))
|
||||
- fix(desktop): surface codex config-parse failures and -32603 internal errors clearly ([#1745](https://github.com/block/buzz/pull/1745)) ([`ec3762552`](https://github.com/block/buzz/commit/ec37625522ce1847e2684da2a477ca742255fbf1))
|
||||
- fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread ([#1747](https://github.com/block/buzz/pull/1747)) ([`e8df6ba69`](https://github.com/block/buzz/commit/e8df6ba694703a26963524433e0e3a3f6ad252f1))
|
||||
- chore(desktop): remove container-only npm-preflight E2E harness ([#1749](https://github.com/block/buzz/pull/1749)) ([`9895b4d39`](https://github.com/block/buzz/commit/9895b4d390591a5adfc368bbc97f53989463c8ef))
|
||||
- fix(desktop): preflight npm prefix writability in doctor installs ([#1732](https://github.com/block/buzz/pull/1732)) ([`58aee8f91`](https://github.com/block/buzz/commit/58aee8f91394a97b68902f61a116696ed8ca3e1f))
|
||||
- fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge ([#1738](https://github.com/block/buzz/pull/1738)) ([`ea1c75b0d`](https://github.com/block/buzz/commit/ea1c75b0d2bf954efe3816b303a9972e26ff893a))
|
||||
- feat(desktop): add explicit Edit avatar CTA in agent instance edit dialog ([#1736](https://github.com/block/buzz/pull/1736)) ([`26966aace`](https://github.com/block/buzz/commit/26966aacec1d8ca58ac5951818e55249e63506a3))
|
||||
- fix(observer): order system prompt below current-session divider on restart ([#1734](https://github.com/block/buzz/pull/1734)) ([`67a2d047e`](https://github.com/block/buzz/commit/67a2d047eb866dff3faae9f96b0a4f478e5e2a8b))
|
||||
|
||||
|
||||
## v0.4.0
|
||||
|
||||
- feat(desktop): default observer archive on for dev-nest builds ([#1726](https://github.com/block/buzz/pull/1726)) ([`d9e4edbbf`](https://github.com/block/buzz/commit/d9e4edbbf54996df22c59afdaaaaf571b4e3a5c6))
|
||||
- feat(desktop): thread baked build env into global agent config UI ([#1722](https://github.com/block/buzz/pull/1722)) ([`2f2ad409a`](https://github.com/block/buzz/commit/2f2ad409a76606dd9c54872dc518b0ecb72756af))
|
||||
- feat: relay invite links (mint + claim + landing page + deep link) ([#1668](https://github.com/block/buzz/pull/1668)) ([`2e529aab7`](https://github.com/block/buzz/commit/2e529aab759a18c1bb81e447f3696fe99db53a27))
|
||||
- fix(desktop): pass GlobalAgentConfig to spawn_config_hash in tests ([#1721](https://github.com/block/buzz/pull/1721)) ([`b97777350`](https://github.com/block/buzz/commit/b97777350dec375a1fe444891c8e10f9cee74d0c))
|
||||
- feat(desktop): global agent config defaults with readiness/spawn parity ([#1448](https://github.com/block/buzz/pull/1448)) ([`77c08365e`](https://github.com/block/buzz/commit/77c08365e4a66c0cc8731b66280ef029500bbfcf))
|
||||
- fix(desktop): remove "Remove all stopped" bulk action from agents menu ([#1720](https://github.com/block/buzz/pull/1720)) ([`651efe8d9`](https://github.com/block/buzz/commit/651efe8d95d11a591d8421973bf4352e36d69a3f))
|
||||
- fix(desktop): retire single-member built-in Fizz team ([#1718](https://github.com/block/buzz/pull/1718)) ([`f12bd3689`](https://github.com/block/buzz/commit/f12bd36893ab37f15261d47d2412732089e1645f))
|
||||
- fix(desktop): hide relay mesh option when mesh-llm feature is absent ([#1719](https://github.com/block/buzz/pull/1719)) ([`f0b8f806f`](https://github.com/block/buzz/commit/f0b8f806fde83b2ea8fc048e1076f4338e445545))
|
||||
- fix(desktop): propagate agent definition edits to instances and remove dead config knobs ([#1715](https://github.com/block/buzz/pull/1715)) ([`1f48860b7`](https://github.com/block/buzz/commit/1f48860b7b5454a07f4f066ccca665dc7ee26b20))
|
||||
- perf(desktop): thread panel rebuilt on every render — fix memo + add typing-latency benchmark ([#1652](https://github.com/block/buzz/pull/1652)) ([`9b2f0c8cb`](https://github.com/block/buzz/commit/9b2f0c8cb574f37eb38033f849b8420d15a63716))
|
||||
- fix(drafts): stop clearing drafts on workspace switch and hide sent section ([#1708](https://github.com/block/buzz/pull/1708)) ([`e2d104f66`](https://github.com/block/buzz/commit/e2d104f669468358ea917f1d7fa2dc9fc5958764))
|
||||
- fix(inbox): reply at same thread level as inbox item ([#1714](https://github.com/block/buzz/pull/1714)) ([`177fc30be`](https://github.com/block/buzz/commit/177fc30bea397b9422c25eb467c33496ebba9cbe))
|
||||
- fix(desktop): make @mention clicks reliably open the profile panel ([#1705](https://github.com/block/buzz/pull/1705)) ([`5f0d8309d`](https://github.com/block/buzz/commit/5f0d8309d266262965489eedea516f95a02fd410))
|
||||
- fix(desktop): walk ancestor chain in orphan sweep exemption ([#1711](https://github.com/block/buzz/pull/1711)) ([`7fb215c4f`](https://github.com/block/buzz/commit/7fb215c4fc29ecf2311f84d8f1f93b0301a582fa))
|
||||
- fix(desktop): stop showing /dev/null in diff labels, keep type badge visible ([#1704](https://github.com/block/buzz/pull/1704)) ([`868c9ad05`](https://github.com/block/buzz/commit/868c9ad05555dbc11d5c1ceece1e0a310bc3ab4a))
|
||||
- perf(desktop): stop re-rendering the app shell on every keystroke; fix two latent cache races it unmasked ([#1692](https://github.com/block/buzz/pull/1692)) ([`129c91a2b`](https://github.com/block/buzz/commit/129c91a2ba0017b20b65cbad8099269a383b46a4))
|
||||
- fix(observer): widen live subscription lookback to capture session/prompt ([#1710](https://github.com/block/buzz/pull/1710)) ([`715df2537`](https://github.com/block/buzz/commit/715df2537594da42c5b19406805c6c410c86a15f))
|
||||
- test(desktop): stabilize custom emoji e2e ([#1685](https://github.com/block/buzz/pull/1685)) ([`20c40d176`](https://github.com/block/buzz/commit/20c40d1769438a1b0fdcd032b3dcfdc79a683898))
|
||||
- fix(observer): use lifecycle item title as verb instead of hardcoded "Status" ([#1709](https://github.com/block/buzz/pull/1709)) ([`95bc640a5`](https://github.com/block/buzz/commit/95bc640a539c87c1fe0560d1dfa960b653bb96a1))
|
||||
- chore(desktop): remove dead archive-migration and keyring load_readonly helpers ([#1701](https://github.com/block/buzz/pull/1701)) ([`389a956e0`](https://github.com/block/buzz/commit/389a956e02e85b1a3379df384a14c6d9c449d3c6))
|
||||
- fix(desktop): stop losing imported identity on every launch ([#1568](https://github.com/block/buzz/pull/1568)) ([`e15f06857`](https://github.com/block/buzz/commit/e15f0685718bcba6d0947a8286ab2deeab0137ef))
|
||||
- fix(desktop): use augmented PATH for readiness probes ([#1613](https://github.com/block/buzz/pull/1613)) ([`94ec2c2bc`](https://github.com/block/buzz/commit/94ec2c2bc6f5505dbbbd4783c41f3bb20d5a642c))
|
||||
- fix(desktop): isolate dev keyring from production and make BUZZ_SHARE_IDENTITY keyring-aware ([#1680](https://github.com/block/buzz/pull/1680)) ([`e5d215e3c`](https://github.com/block/buzz/commit/e5d215e3c2f4939fe4d0e1f09c8f9a265f818385))
|
||||
- fix(desktop): migrate Databricks V1→V2 records at boot and fix readiness gate ([#1686](https://github.com/block/buzz/pull/1686)) ([`1f2afce29`](https://github.com/block/buzz/commit/1f2afce29d46ec09f63d467af4cacdd479a9a336))
|
||||
- B5 residue: inbound-event security hardening + persona→agent copy sweep ([#1688](https://github.com/block/buzz/pull/1688)) ([`f0e65589a`](https://github.com/block/buzz/commit/f0e65589a9a68cd19457e1196b8eb4db68217a3b))
|
||||
- fix(desktop): paginate complete channel directory ([#1690](https://github.com/block/buzz/pull/1690)) ([`b41cf3ffc`](https://github.com/block/buzz/commit/b41cf3ffc6d232dc1199f7563bd3c4da8571bfe1))
|
||||
- fix: flush debounce on Tab/Enter to prevent stale autocomplete ([#1661](https://github.com/block/buzz/pull/1661)) ([`841282141`](https://github.com/block/buzz/commit/841282141b5feb728dd111c9163cd0060f9bbb86))
|
||||
- fix(desktop): preserve agent timer state across workspace switches ([#1679](https://github.com/block/buzz/pull/1679)) ([`46613dcce`](https://github.com/block/buzz/commit/46613dccec89b064b2ba61d65b191c155a5aac3f))
|
||||
- fix(desktop): honor macOS title-bar double-click preference ([#1674](https://github.com/block/buzz/pull/1674)) ([`6b2147a5d`](https://github.com/block/buzz/commit/6b2147a5d18d70a3a46d2484d746de3506f8138d))
|
||||
- feat(desktop): projects overview v2 — aggregate rail, PR review flow, commit detail, and straighter layout ([#1677](https://github.com/block/buzz/pull/1677)) ([`58fe9388c`](https://github.com/block/buzz/commit/58fe9388c81ace47001cba55f29afb76f288f3e0))
|
||||
- B5: one create path — behavioral quad activation, backfill, menu collapse, legacy dialog deletion ([#1667](https://github.com/block/buzz/pull/1667)) ([`3a7e40027`](https://github.com/block/buzz/commit/3a7e4002787ea3e8f0f91a62fdbd2f1eb3e7a49f))
|
||||
- Polish Buzz sidebar theme ([#1671](https://github.com/block/buzz/pull/1671)) ([`cdba2a08e`](https://github.com/block/buzz/commit/cdba2a08e1645f63e71965771cfbbf4a1437f169))
|
||||
- fix(agents): harden structured error-code classification edge cases ([#1663](https://github.com/block/buzz/pull/1663)) ([`fc8a17445`](https://github.com/block/buzz/commit/fc8a17445a5712d0a1456684d78952edf41cf5ae))
|
||||
- feat: add deeplink nostr identity binding flow ([#1648](https://github.com/block/buzz/pull/1648)) ([`cecd03142`](https://github.com/block/buzz/commit/cecd031428358d7b3aa0326ccca74649bd928231))
|
||||
- perf(desktop): cache parsed markdown across channel-switch remounts ([#1635](https://github.com/block/buzz/pull/1635)) ([`2b3246934`](https://github.com/block/buzz/commit/2b324693404623b2fa2e53ab6ec570977526ace3))
|
||||
- fix(agents): surface provider errors structurally instead of raw dumps ([#1653](https://github.com/block/buzz/pull/1653)) ([`9b244ff22`](https://github.com/block/buzz/commit/9b244ff22b2fdab1d4aa20fcf661d22aa0cf84aa))
|
||||
- feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) ([#1655](https://github.com/block/buzz/pull/1655)) ([`b2c63291f`](https://github.com/block/buzz/commit/b2c63291f1e432951f49c57bd1ec65812870f515))
|
||||
- fix(desktop): scope observer feed by channel and add session-boundary dividers ([#1634](https://github.com/block/buzz/pull/1634)) ([`fd5d04de0`](https://github.com/block/buzz/commit/fd5d04de0593ee8c335b57bd2d248c638b055ade))
|
||||
- feat(config-nudge): distinguish install/auth state in cli_login nudge cards ([#1633](https://github.com/block/buzz/pull/1633)) ([`4f1a487ab`](https://github.com/block/buzz/commit/4f1a487ab49531d4ebe284fd3cee689c13651c7f))
|
||||
- fix(desktop): async-ify the auto-restart setter after the perf-sweep merge collision ([#1651](https://github.com/block/buzz/pull/1651)) ([`dc7ececdc`](https://github.com/block/buzz/commit/dc7ececdccd8ed5675f1560a5319518ade6b5e8e))
|
||||
- feat(desktop): auto-restart agents on config change (Chunk F) ([#1649](https://github.com/block/buzz/pull/1649)) ([`a415f0f31`](https://github.com/block/buzz/commit/a415f0f3109fae970d266474fec64f16bab53b33))
|
||||
- perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization ([#1641](https://github.com/block/buzz/pull/1641)) ([`2cc0eb539`](https://github.com/block/buzz/commit/2cc0eb53968ec6281be0e6778a06ea18e044395e))
|
||||
- fix: user search returns zero results for partially typed names ([#1603](https://github.com/block/buzz/pull/1603)) ([`11608ef67`](https://github.com/block/buzz/commit/11608ef675ef11c375b2ac9c04bdcc287affeb46))
|
||||
- copy(desktop): finish the persona→agent rename in rendered UI (B4.1) ([#1647](https://github.com/block/buzz/pull/1647)) ([`577fc8df5`](https://github.com/block/buzz/commit/577fc8df5254f8fc1c5ec260d22f64f8786c6244))
|
||||
- Community moderation UI: integration branch (data layer + member surface base) ([#1617](https://github.com/block/buzz/pull/1617)) ([`c9c868b25`](https://github.com/block/buzz/commit/c9c868b250e1dc8f6019abaaa6ae2c203294dfc4))
|
||||
- Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices ([#1616](https://github.com/block/buzz/pull/1616)) ([`863aeb79f`](https://github.com/block/buzz/commit/863aeb79f35a43222bd5de3901ab06220948fb9d))
|
||||
- copy(desktop): the word "persona" leaves the UI (Phase 1B.4) ([#1646](https://github.com/block/buzz/pull/1646)) ([`41b187f93`](https://github.com/block/buzz/commit/41b187f93646403689311d5f5bfcbc4cc1bf4927))
|
||||
- feat(desktop): flapping bee on the setup loading screen ([#1631](https://github.com/block/buzz/pull/1631)) ([`c145037aa`](https://github.com/block/buzz/commit/c145037aad121bb9f6fd6e9068ff3cf77fb86a48))
|
||||
- fix(desktop): make ⌘K open the composer link editor for selections ([#1644](https://github.com/block/buzz/pull/1644)) ([`3e982fd37`](https://github.com/block/buzz/commit/3e982fd371b97aebf39a3e418e323e6c0de7b665))
|
||||
- fix(linux): make AppImage work on Mesa 25+ / GLib 2.88 distros ([#1567](https://github.com/block/buzz/pull/1567)) ([`563dab92e`](https://github.com/block/buzz/commit/563dab92e4f07b87db0c4156a20b0041c60b561c))
|
||||
- fix(desktop): add unread dot to workspace rail for channel unreads ([#1637](https://github.com/block/buzz/pull/1637)) ([`f968601ef`](https://github.com/block/buzz/commit/f968601ef6208ce411989617d15b2f3592a51665))
|
||||
- refactor(desktop): converge the three definition→instance mappings (Phase 1B.3.5) ([#1645](https://github.com/block/buzz/pull/1645)) ([`9683ef807`](https://github.com/block/buzz/commit/9683ef807919f4d04f960280bff3b9d99da12af3))
|
||||
- Hide non-invocable member agents from @-mention autocomplete ([#1611](https://github.com/block/buzz/pull/1611)) ([`f929aa09e`](https://github.com/block/buzz/commit/f929aa09e685eece9ff710c7506b07834df1f00e))
|
||||
- fix(desktop): clamp HDR avatar images to SDR range ([#1642](https://github.com/block/buzz/pull/1642)) ([`fa816d5bf`](https://github.com/block/buzz/commit/fa816d5bfade9eb84e93b720bfc55e6af4e623f4))
|
||||
- refactor(desktop): definition-edit routes through AgentDialog — single dialog entry point (Phase 1B.3c) ([#1643](https://github.com/block/buzz/pull/1643)) ([`aa17294ad`](https://github.com/block/buzz/commit/aa17294ad07c0fa64b94502e9e9a07aa9bd5c70d))
|
||||
- fix(desktop): refresh persona env vars on respawn — record.env_vars is overrides-only ([#1640](https://github.com/block/buzz/pull/1640)) ([`79a057534`](https://github.com/block/buzz/commit/79a0575342016b8aebeff322e84c9cea0bf76443))
|
||||
- refactor(desktop): re-host instance edit as AgentInstanceEditDialog behind AgentDialog (Phase 1B.3b) ([#1639](https://github.com/block/buzz/pull/1639)) ([`8af18ee32`](https://github.com/block/buzz/commit/8af18ee32515db20ec5b4d3d430a49f702209cdc))
|
||||
- feat(desktop): show full URL tooltip on masked link hover ([#1625](https://github.com/block/buzz/pull/1625)) ([`01c6b8566`](https://github.com/block/buzz/commit/01c6b8566e7d6ccd291c4a244285927476196b6d))
|
||||
- fix(desktop): paint Buzz gradient on the workspace rail ([#1636](https://github.com/block/buzz/pull/1636)) ([`2f1cd6771`](https://github.com/block/buzz/commit/2f1cd677147b5d07979cef112c728a96a4dff4f1))
|
||||
- refactor(desktop): single-home the definition form as AgentDefinitionDialog (Phase 1B.3a) ([#1627](https://github.com/block/buzz/pull/1627)) ([`48bd8abfe`](https://github.com/block/buzz/commit/48bd8abfeb165f23f95d456be91b45c4b698a0df))
|
||||
- feat(desktop): unified AgentDialog create entry point with create intents (Phase 1B.2) ([#1626](https://github.com/block/buzz/pull/1626)) ([`6f38db680`](https://github.com/block/buzz/commit/6f38db68039300d7f92122b9d7631b5a4bbe5e53))
|
||||
- Update DMG background and label size ([#1629](https://github.com/block/buzz/pull/1629)) ([`5b1a53ceb`](https://github.com/block/buzz/commit/5b1a53ceba2c6d9196a52b478c1f581c4d42b8df))
|
||||
- test(desktop): deflake mid-scroll older-history spinner smoke test ([#1632](https://github.com/block/buzz/pull/1632)) ([`7ff58d9af`](https://github.com/block/buzz/commit/7ff58d9af9edbdfd3f1a8c508641dda12710320a))
|
||||
- feat(desktop): add Buzz light + dark themes with branded sidebar gradient ([#1630](https://github.com/block/buzz/pull/1630)) ([`290be2a03`](https://github.com/block/buzz/commit/290be2a0395c1d2151ae5524c5474d95cffc302c))
|
||||
- fix(desktop): render thread facepile oldest-replier-first ([#1595](https://github.com/block/buzz/pull/1595)) ([`f9701a386`](https://github.com/block/buzz/commit/f9701a38600302b3a97845b6f25681b6ef63dbd7))
|
||||
- fix(desktop): keep sidebar section actions visible while ⋮ menu is open ([#1584](https://github.com/block/buzz/pull/1584)) ([`943d7a663`](https://github.com/block/buzz/commit/943d7a663028a32485f3a372a1ae73286c225e95))
|
||||
- refactor(desktop): extract shared runtime/provider/model selection + env-var helpers (Phase 1B.1) ([#1624](https://github.com/block/buzz/pull/1624)) ([`4b5cac074`](https://github.com/block/buzz/commit/4b5cac074b10dab75404aa09f65d5a4e43ff4848))
|
||||
- feat(desktop): fold personas.json into the unified agent store (Phase 1A.2) ([#1623](https://github.com/block/buzz/pull/1623)) ([`381c7714f`](https://github.com/block/buzz/commit/381c7714fa6943f033eb3963aa105397098b782b))
|
||||
- fix(desktop): hash spawn config as the env receives it, not raw record fields ([#1621](https://github.com/block/buzz/pull/1621)) ([`f0eb4eccd`](https://github.com/block/buzz/commit/f0eb4eccd9dbae6819d44c282db675c89f8e972a))
|
||||
- test(desktop): add edit-agent dialog e2e coverage (Phase 1B.3b-pre) ([#1628](https://github.com/block/buzz/pull/1628)) ([`a609e796b`](https://github.com/block/buzz/commit/a609e796bd1b5eda01c733041e5e5320c2f4557d))
|
||||
- feat(desktop): unified AgentRecord groundwork — record-first resolution + runtime materialization (Phase 1A.1) ([#1618](https://github.com/block/buzz/pull/1618)) ([`5dce80aff`](https://github.com/block/buzz/commit/5dce80aff53ca698c1c17ac21866bc3949ed14bc))
|
||||
- fix(desktop): backfill edits for thread replies so edited replies survive refetch ([#1610](https://github.com/block/buzz/pull/1610)) ([`d1f3194e7`](https://github.com/block/buzz/commit/d1f3194e73abd1389c62b32e0502fcf8c5ce9b4c))
|
||||
- chore(desktop): clear desktop-tauri clippy backlog ([#1612](https://github.com/block/buzz/pull/1612)) ([`36fd41c5c`](https://github.com/block/buzz/commit/36fd41c5ce63bf0f8f0ad8d740fce47cae7ef9d1))
|
||||
- fix(desktop): restrict shared-agent sync to dev data dirs ([#1597](https://github.com/block/buzz/pull/1597)) ([`e5f831d2c`](https://github.com/block/buzz/commit/e5f831d2c2358592e24a20fb020f43fc50ea9d1b))
|
||||
- feat(desktop): restart-required badge from spawn-time config hash ([#1602](https://github.com/block/buzz/pull/1602)) ([`cc42a4979`](https://github.com/block/buzz/commit/cc42a497991ee6fe632734239c53a7c4d4fb4424))
|
||||
- feat(desktop): boot-time reconcile of managed agents to relay events ([#1601](https://github.com/block/buzz/pull/1601)) ([`c5a541ee2`](https://github.com/block/buzz/commit/c5a541ee2d2436d0fb970e5c0164bed349a76c4a))
|
||||
- feat(desktop): canonical <PubKey> component — hover to view/copy full keys, owner "you" labels ([#1589](https://github.com/block/buzz/pull/1589)) ([`777babf39`](https://github.com/block/buzz/commit/777babf3938a2ae7ef97fd12f12e6247e7c18cec))
|
||||
- fix(desktop): hydrate reactions for Inbox context messages ([#1596](https://github.com/block/buzz/pull/1596)) ([`cdf982bdb`](https://github.com/block/buzz/commit/cdf982bdb3fa4693416104a3e26a324e7703b58c))
|
||||
|
||||
|
||||
## v0.3.46
|
||||
|
||||
- fix(desktop): preserve agent model/provider when persona snapshot fields are blank ([#1583](https://github.com/block/buzz/pull/1583)) ([`a3ee2c569`](https://github.com/block/buzz/commit/a3ee2c5693d58f32b17072a49add2dbaff8e0ffd))
|
||||
- feat(acp,desktop): identify and reap stale agent harness processes ([#1582](https://github.com/block/buzz/pull/1582)) ([`3c6c0d447`](https://github.com/block/buzz/commit/3c6c0d4478e5bb6e734cc16eafa6de6a433bf8fe))
|
||||
- feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state ([#1581](https://github.com/block/buzz/pull/1581)) ([`b394e95ef`](https://github.com/block/buzz/commit/b394e95ef03b131fb43bda0954d900ae56473f31))
|
||||
- fix(desktop): treat baked build env vars as satisfying required agent config ([#1580](https://github.com/block/buzz/pull/1580)) ([`b90e80443`](https://github.com/block/buzz/commit/b90e80443ea498e2b7b97bde9069d4b7c6ab4048))
|
||||
- feat(desktop): add "Copy image" to image right-click context menu ([#1579](https://github.com/block/buzz/pull/1579)) ([`683f7fec1`](https://github.com/block/buzz/commit/683f7fec1c357f1140a4f874720f513b69d919a4))
|
||||
- fix(nest): use buzz-dev symlink name for dev builds ([#1587](https://github.com/block/buzz/pull/1587)) ([`dcbb3ff78`](https://github.com/block/buzz/commit/dcbb3ff7892891cdc3c447049444cd2be8570f5e))
|
||||
- fix(composer): address image-editor follow-up nits on #1491 ([#1565](https://github.com/block/buzz/pull/1565)) ([`49391d807`](https://github.com/block/buzz/commit/49391d80713b74d1f48a442b193d9a91b086d99a))
|
||||
- fix(desktop): render black static boot screen ([#1570](https://github.com/block/buzz/pull/1570)) ([`10444d248`](https://github.com/block/buzz/commit/10444d248e26f6ab119cb13ccef906f42dc333da))
|
||||
- feat(agents): group activity tool bursts ([#1571](https://github.com/block/buzz/pull/1571)) ([`426f04484`](https://github.com/block/buzz/commit/426f044844951fc1ced7670d2dd5d25812deced9))
|
||||
- feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs ([#1573](https://github.com/block/buzz/pull/1573)) ([`c97263901`](https://github.com/block/buzz/commit/c97263901f9757ed6e9ddbf3f47097e2c6567e24))
|
||||
- fix(desktop): fetch profiles for reaction actors and thread-reply authors ([#1550](https://github.com/block/buzz/pull/1550)) ([`62def1459`](https://github.com/block/buzz/commit/62def1459c6e4d2beb5fda9e0c4accde16aa2c8d))
|
||||
- refactor(desktop): unify EditAgentDialog styling with PersonaDialog ([#1540](https://github.com/block/buzz/pull/1540)) ([`c258ffc4a`](https://github.com/block/buzz/commit/c258ffc4a87fb523900801d0959334912f25d30a))
|
||||
- feat(desktop): add 10-minute message grouping window ([#1578](https://github.com/block/buzz/pull/1578)) ([`564ead385`](https://github.com/block/buzz/commit/564ead3856c1e9f5c8760aaa6bcc9bd16cbbd9be))
|
||||
- feat(desktop): unify sidebar section actions into a per-section ⋮ menu ([#1577](https://github.com/block/buzz/pull/1577)) ([`d94561795`](https://github.com/block/buzz/commit/d94561795a48af743278bfa1e912b894228312f3))
|
||||
- feat(desktop): emoji avatar picker for agents + reliable picker scroll ([#1576](https://github.com/block/buzz/pull/1576)) ([`ed84a8735`](https://github.com/block/buzz/commit/ed84a87358495a4cdd6e61ee1cfe01db9a367b35))
|
||||
- fix(desktop): sync derived active-turn liveness in channel activity path ([#1492](https://github.com/block/buzz/pull/1492)) ([`1f5ba5bb2`](https://github.com/block/buzz/commit/1f5ba5bb27a94d33cce5aaa7cfea1d116c7d582d))
|
||||
- fix(personas): remove goose runtime pin from Fizz built-in ([#1566](https://github.com/block/buzz/pull/1566)) ([`e3008b360`](https://github.com/block/buzz/commit/e3008b3604330803975d66920204e7365fc571a9))
|
||||
- feat(acp,buzz-agent): thread model name through NIP-AM kind 44200 emit path ([#1564](https://github.com/block/buzz/pull/1564)) ([`08e707eb5`](https://github.com/block/buzz/commit/08e707eb573a187432a950154a15f9cb4785d25f))
|
||||
- sync channel sort preferences across clients ([#1556](https://github.com/block/buzz/pull/1556)) ([`04a9d1029`](https://github.com/block/buzz/commit/04a9d10299b92c5b0e7597a53914b9a753985c51))
|
||||
- fix desktop canvas header blur in split channel management ([#1558](https://github.com/block/buzz/pull/1558)) ([`2ef26ec21`](https://github.com/block/buzz/commit/2ef26ec21048857fc11982cd8c153e1c795f2c6d))
|
||||
- feat(composer): compose spoiler, annotation, and tooltip controls ([#1491](https://github.com/block/buzz/pull/1491)) ([`1aa87bb26`](https://github.com/block/buzz/commit/1aa87bb26b96d13fa2bba042ccd933e988d79bae))
|
||||
- feat(channel list): add persistent channel sort toggle ([#1505](https://github.com/block/buzz/pull/1505)) ([`36ac5243f`](https://github.com/block/buzz/commit/36ac5243ff8aed4401d3453a115afc4a603fccf0))
|
||||
|
||||
|
||||
## v0.3.45
|
||||
|
||||
- chore(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#1563](https://github.com/block/buzz/pull/1563)) ([`75b52ad7e`](https://github.com/block/buzz/commit/75b52ad7e54ca7f930656e756910a08d0f146b82))
|
||||
- fix(archive): atomic remove-kind path + split test modules ([#1562](https://github.com/block/buzz/pull/1562)) ([`484bede75`](https://github.com/block/buzz/commit/484bede7597814dd7dc322d1f72f69c08545ef42))
|
||||
- feat(archive): add agent turn-metric (kind 44200) local archive ([#1555](https://github.com/block/buzz/pull/1555)) ([`429f54b4f`](https://github.com/block/buzz/commit/429f54b4f45df0d5f4a52aafb8913e903ebdc6b3))
|
||||
- feat(desktop,buzz-acp): add harness-agnostic config bridge and setup-listener mode ([#1411](https://github.com/block/buzz/pull/1411)) ([`d8355d318`](https://github.com/block/buzz/commit/d8355d318853f85ffaf53dc7b5215bc998b460c4))
|
||||
- fix(desktop): stop edit-channel dialog hanging on "Saving..." ([#1557](https://github.com/block/buzz/pull/1557)) ([`76806cb1f`](https://github.com/block/buzz/commit/76806cb1f654650a412b7f0368f495062a9ee224))
|
||||
- feat(archive): add local-save archive with observer-feed default-on option ([#1442](https://github.com/block/buzz/pull/1442)) ([`711078807`](https://github.com/block/buzz/commit/711078807777db77960404e51ffd862c08a33ded))
|
||||
- feat(desktop): add Slack-like Drafts inbox with persistence and image fix ([#1539](https://github.com/block/buzz/pull/1539)) ([`4c598dcef`](https://github.com/block/buzz/commit/4c598dcef577bb204f9666eaac5b95743a0f8736))
|
||||
- fix(setup): fix syntax error in seed-local-community.sh ([#1547](https://github.com/block/buzz/pull/1547)) ([`3729a6515`](https://github.com/block/buzz/commit/3729a6515617abdae03117d9713dfeb7e1ff74c2))
|
||||
- feat(desktop): add right-click context menu to workspace rail ([#1552](https://github.com/block/buzz/pull/1552)) ([`0e87998f8`](https://github.com/block/buzz/commit/0e87998f8fcac0ff1612d661cbd505a63b51c550))
|
||||
- fix(desktop): restore saved window geometry on launch ([#1554](https://github.com/block/buzz/pull/1554)) ([`e790c9828`](https://github.com/block/buzz/commit/e790c9828f1ae85ce7618d06e67e78732be4221b))
|
||||
- feat(desktop): add typeahead search to persona model dropdown ([#1542](https://github.com/block/buzz/pull/1542)) ([`3b43743e1`](https://github.com/block/buzz/commit/3b43743e1f2acd192705a80629ceadfa7a737831))
|
||||
- fix(desktop): publish new agent profiles to active relay ([#1546](https://github.com/block/buzz/pull/1546)) ([`346efe08b`](https://github.com/block/buzz/commit/346efe08b3ca6e847b6c9c2f843cdb8834db123c))
|
||||
- chore(desktop): use dot access for host header assignment ([#1548](https://github.com/block/buzz/pull/1548)) ([`ccfdf45bc`](https://github.com/block/buzz/commit/ccfdf45bc5dd61ef7d59644293d84deed24e1789))
|
||||
- fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal ([#1508](https://github.com/block/buzz/pull/1508)) ([`c2ee4d162`](https://github.com/block/buzz/commit/c2ee4d162753a3338dc434f85c80a82eb284b3d2))
|
||||
|
||||
|
||||
## v0.3.44
|
||||
|
||||
- fix(desktop): keep thread-summary badges mounted through scrollback prepends ([#1533](https://github.com/block/buzz/pull/1533)) ([`6abf614fd`](https://github.com/block/buzz/commit/6abf614fd4eb512cf2e4175f17dc09892a8b472d))
|
||||
- refactor(shell): drop bundled PortableGit, add BUZZ_SHELL override + dialect hint ([#1536](https://github.com/block/buzz/pull/1536)) ([`cfa208983`](https://github.com/block/buzz/commit/cfa20898313080b7a106ae0e092d8ed6815daeb0))
|
||||
- feat(nips,relay,acp): NIP-AM durable encrypted agent turn metrics (kind 44200) ([#1441](https://github.com/block/buzz/pull/1441)) ([`71265ca36`](https://github.com/block/buzz/commit/71265ca36105dbf62453a99c998c3f3dd134a304))
|
||||
|
||||
|
||||
## v0.3.43
|
||||
|
||||
- fix(desktop): sync agent relay profile when persona avatar changes ([#1512](https://github.com/block/buzz/pull/1512)) ([`ce901f7c1`](https://github.com/block/buzz/commit/ce901f7c1e548a0aab0d9e97f4951ea134f6b3d2))
|
||||
- feat(desktop): redesign appearance settings with mode-first theme picker ([#1528](https://github.com/block/buzz/pull/1528)) ([`9e773f103`](https://github.com/block/buzz/commit/9e773f103a2829d1b0195a2bf4f01a4b3ff69291))
|
||||
- fix(deps): restore windows-* crates downgraded by chrono bump (fixes Windows Rust on main) ([#1532](https://github.com/block/buzz/pull/1532)) ([`d40837085`](https://github.com/block/buzz/commit/d408370851643fca1525cdb00147e589e7408f21))
|
||||
- chore(renovate): stop rebase churn between weekly sweeps ([#1530](https://github.com/block/buzz/pull/1530)) ([`a8238cd67`](https://github.com/block/buzz/commit/a8238cd670031bf3595fa8d0f2e79e6f64571054))
|
||||
- fix(deps): unversion isomorphic-git pnpm patch key ([#1529](https://github.com/block/buzz/pull/1529)) ([`2a24765fd`](https://github.com/block/buzz/commit/2a24765fd74f66764890746a84f9654e578986f3))
|
||||
- chore(deps): update dependency @tanstack/react-virtual to v3.14.5 ([#1523](https://github.com/block/buzz/pull/1523)) ([`b66a53f5b`](https://github.com/block/buzz/commit/b66a53f5b5ba4355511043ab621f1219417b0a4b))
|
||||
- chore(deps): update redis to v1.2.2 ([#1049](https://github.com/block/buzz/pull/1049)) ([`f1b29140c`](https://github.com/block/buzz/commit/f1b29140c66e2987fedb0fa66174552754721d97))
|
||||
- feat(desktop): restore section icon/emoji picker ([#1516](https://github.com/block/buzz/pull/1516)) ([`232b6066c`](https://github.com/block/buzz/commit/232b6066c0fe54e849537aaa8da644d42bc3e33c))
|
||||
- chore(deps): update radix-ui-primitives monorepo ([#1524](https://github.com/block/buzz/pull/1524)) ([`dc383c199`](https://github.com/block/buzz/commit/dc383c1993c5dfe8fea95f4e10f1e5ee5648f7aa))
|
||||
- fix(desktop): stop Leave-channel dialog from freezing the app ([#1482](https://github.com/block/buzz/pull/1482)) ([`668d30b0c`](https://github.com/block/buzz/commit/668d30b0cf7a466480d07d024583963dc51e2d51))
|
||||
- perf: lazy-load avatars and message images ([#1517](https://github.com/block/buzz/pull/1517)) ([`d09d7dea5`](https://github.com/block/buzz/commit/d09d7dea558bcd17d66315ca099a5a2752a3be60))
|
||||
- Live thread-summary push: badge counts update on reply ingest ([#1521](https://github.com/block/buzz/pull/1521)) ([`74a30c3de`](https://github.com/block/buzz/commit/74a30c3de8ff61005fecac53eb007e8a616a9229))
|
||||
- chore(deps): update all non-major dependencies ([#1163](https://github.com/block/buzz/pull/1163)) ([`a255a7735`](https://github.com/block/buzz/commit/a255a7735855048065b3cc4e530b4af5a70f444d))
|
||||
- chore(deps): update rust crate anyhow to v1.0.103 ([#1525](https://github.com/block/buzz/pull/1525)) ([`26d609cbe`](https://github.com/block/buzz/commit/26d609cbe1770b3bb5809c57bb0d66e775e6ac63))
|
||||
- chore(deps): update cashapp/activate-hermit digest to cea9af7 ([#1522](https://github.com/block/buzz/pull/1522)) ([`19d98319a`](https://github.com/block/buzz/commit/19d98319a811cbc63dd34300ac732fa9941f054a))
|
||||
- chore(deps): update rust crate chrono to v0.4.45 ([#1050](https://github.com/block/buzz/pull/1050)) ([`3caa66d4f`](https://github.com/block/buzz/commit/3caa66d4f047fced63626667261372d49d3c04a9))
|
||||
- chore(deps): update actions/cache digest to caa2961 ([#1341](https://github.com/block/buzz/pull/1341)) ([`8dea817fa`](https://github.com/block/buzz/commit/8dea817fa97b384b7ea8a2f20789300d5e0a8c60))
|
||||
- chore(deps): pin dependencies ([#1162](https://github.com/block/buzz/pull/1162)) ([`4b01040af`](https://github.com/block/buzz/commit/4b01040afb3cc778f3a2a1d8f89af37352b2ef35))
|
||||
- feat(desktop): replace placeholder icon with actual app icon on welcome screen ([#1527](https://github.com/block/buzz/pull/1527)) ([`5b88e5956`](https://github.com/block/buzz/commit/5b88e595657cf4ffc740e30b187439fe53547331))
|
||||
- feat(desktop): enable native spellcheck in message composer ([#1515](https://github.com/block/buzz/pull/1515)) ([`a1b5983e5`](https://github.com/block/buzz/commit/a1b5983e5400a6bd9f4c8056086de616931e33b4))
|
||||
- fix: let agent owners delete their agent's messages (relay kind:5 + desktop/mobile UX) ([#1519](https://github.com/block/buzz/pull/1519)) ([`642800548`](https://github.com/block/buzz/commit/6428005487f0690019dc27449a6a52cc29cc6479))
|
||||
- chore(desktop): bring ChannelScreen back under the size gate ([#1520](https://github.com/block/buzz/pull/1520)) ([`8174f2b0a`](https://github.com/block/buzz/commit/8174f2b0a0aee3d0252ef8923a043b2aa2773499))
|
||||
- fix(zoom) desktop chrome clearance under text zoom ([#1490](https://github.com/block/buzz/pull/1490)) ([`ee4c9ef13`](https://github.com/block/buzz/commit/ee4c9ef139724a3cb82841d449674e6c96932b7b))
|
||||
- fix(activity panel): handle back navigation ([#1487](https://github.com/block/buzz/pull/1487)) ([`caf644eda`](https://github.com/block/buzz/commit/caf644eda730c8472183e6801d428f3fd8c05ef7))
|
||||
- Port channel windows to mobile ([#1518](https://github.com/block/buzz/pull/1518)) ([`6716cd31a`](https://github.com/block/buzz/commit/6716cd31a5303f3964a7acac8159c365d28f55f0))
|
||||
- perf: GIN index for e-tag containment + delta profile fetch (scroll-back ~2.1s/page) ([#1514](https://github.com/block/buzz/pull/1514)) ([`33886e3de`](https://github.com/block/buzz/commit/33886e3dec4130e512d8242207adfe2811a92579))
|
||||
- GUI read-model overhaul: server-assembled channel windows (Correct™ pagination + relay-signed bounds) ([#1500](https://github.com/block/buzz/pull/1500)) ([`62bb9fe8c`](https://github.com/block/buzz/commit/62bb9fe8c81eee6573c434ffa3227fa96ad9dd4b))
|
||||
- feat(desktop): show activity timestamps on demand ([#1506](https://github.com/block/buzz/pull/1506)) ([`c7a8b0bab`](https://github.com/block/buzz/commit/c7a8b0babb35e296fbc4a1fc3df0015518085d32))
|
||||
- feat(reconnect): replace top banner with animated sidebar overlay ([#1510](https://github.com/block/buzz/pull/1510)) ([`f35aeb798`](https://github.com/block/buzz/commit/f35aeb7986b2751f3ff2e4e33403aa5c28af0b43))
|
||||
- docs(nest-skill): explain agent-owned git repos and automatic auth ([#1437](https://github.com/block/buzz/pull/1437)) ([`20d3cdc22`](https://github.com/block/buzz/commit/20d3cdc22f4a53a792c4b249ef0338ff6411581d))
|
||||
- fix(agent): make stop-hook rejection budget per-prompt, fix stale hook docs ([#1503](https://github.com/block/buzz/pull/1503)) ([`4a0951042`](https://github.com/block/buzz/commit/4a095104297a58e50d3ffb0ce600162b9a298323))
|
||||
|
||||
|
||||
## v0.3.42
|
||||
|
||||
- fix(desktop): bound read-state localStorage growth and recover from quota errors ([#1502](https://github.com/block/buzz/pull/1502)) ([`a3cf7eec1`](https://github.com/block/buzz/commit/a3cf7eec1d5c876c65aabc827620ca2b7b79127b))
|
||||
- Customize macOS DMG installer ([#1496](https://github.com/block/buzz/pull/1496)) ([`bbf8d8912`](https://github.com/block/buzz/commit/bbf8d8912a19d9f8f249b3b7897999e343f803dd))
|
||||
- mobile: thread scroll-to-bottom and desktop-parity mention autocomplete ([#1499](https://github.com/block/buzz/pull/1499)) ([`e9318f66d`](https://github.com/block/buzz/commit/e9318f66d8fe5bff1df8afb7a2dcc36baf0876ac))
|
||||
- fix(agent): honor stop hook retry budget ([#1501](https://github.com/block/buzz/pull/1501)) ([`1c297d2f2`](https://github.com/block/buzz/commit/1c297d2f26688c35718e90a875e0bece41392253))
|
||||
- feat(profile): embed live activity feed in profile aux panel ([#1380](https://github.com/block/buzz/pull/1380)) ([`654b6c374`](https://github.com/block/buzz/commit/654b6c374b3f8a2f2a59c72f604bc5de4546f53f))
|
||||
- feat(desktop): contribution heatmap and graphical cards on projects overview ([#1497](https://github.com/block/buzz/pull/1497)) ([`204a0fd2e`](https://github.com/block/buzz/commit/204a0fd2ec2332cdbad7279be537aee27f20a70d))
|
||||
- feat(desktop): repository-first projects with git workflows ([#1471](https://github.com/block/buzz/pull/1471)) ([`8e3c0ee95`](https://github.com/block/buzz/commit/8e3c0ee958af8777ba54fd835de03b0e8eada531))
|
||||
- fix(desktop): lock horizontal webview pan (Magic Mouse side-scroll) ([#1480](https://github.com/block/buzz/pull/1480)) ([`5d4edf153`](https://github.com/block/buzz/commit/5d4edf1535c4b19543e1f57722f4dc94b5af675c))
|
||||
- Add agent catalog modal ([#1302](https://github.com/block/buzz/pull/1302)) ([`228122fdd`](https://github.com/block/buzz/commit/228122fddcc35cef0cb69c89892276c9b5986c25))
|
||||
- fix(desktop): actually close relay sockets — plugin:websocket|disconnect does not exist ([#1481](https://github.com/block/buzz/pull/1481)) ([`e70dd1a1e`](https://github.com/block/buzz/commit/e70dd1a1e70cc0f56aa6094e4da22cd91e3203b0))
|
||||
- feat(buzz-agent): config parity — thinking effort, model switching, normalized token limits ([#1470](https://github.com/block/buzz/pull/1470)) ([`3e282a241`](https://github.com/block/buzz/commit/3e282a24181aeef1c2cf06d4e4bc3b1c87f60067))
|
||||
- fix(sidebar): scope channel sections storage to relay URL ([#1477](https://github.com/block/buzz/pull/1477)) ([`f9d06ae21`](https://github.com/block/buzz/commit/f9d06ae21aaa20c3de299a6f7065863e9e8d76b3))
|
||||
- chore(mobile): declare non-exempt encryption usage in Info.plist ([#1474](https://github.com/block/buzz/pull/1474)) ([`453b8b1e4`](https://github.com/block/buzz/commit/453b8b1e47d0a13fb568b2f9b797113c4d663c0f))
|
||||
- fix(desktop): bind channel and thread context at compose time to prevent wrong-channel send ([#1472](https://github.com/block/buzz/pull/1472)) ([`d369ca9df`](https://github.com/block/buzz/commit/d369ca9df1248e2ee16a40b2a193bf08dd8126c4))
|
||||
- fix(relay-reconnect): resilient reconnect with fast-path, escalation, and polling ([#1456](https://github.com/block/buzz/pull/1456)) ([`02ff06cac`](https://github.com/block/buzz/commit/02ff06cac230ae708e1dffaa10bf3f71351ae582))
|
||||
- feat: per-community workspace icon set by admins, served via NIP-11 ([#1463](https://github.com/block/buzz/pull/1463)) ([`5bfd5ca27`](https://github.com/block/buzz/commit/5bfd5ca2700483498e83224a40a5628a29cf2e9e))
|
||||
- perf(relay): batch outbound websocket data frames ([#1464](https://github.com/block/buzz/pull/1464)) ([`01b92faa1`](https://github.com/block/buzz/commit/01b92faa156648835f143e84583b8ec3bd7490ab))
|
||||
- Make reaction ingest atomic ([#1458](https://github.com/block/buzz/pull/1458)) ([`835302cc8`](https://github.com/block/buzz/commit/835302cc829c8a63bf254d3e40156fc446e040f6))
|
||||
- Serialize fan-out EVENT frames once ([#1459](https://github.com/block/buzz/pull/1459)) ([`3c661fb48`](https://github.com/block/buzz/commit/3c661fb48f81c294b529592d2b2ff874bf96ee96))
|
||||
- fix: agent reliability — no restart on channel-add, visible dead-letter notice ([#1468](https://github.com/block/buzz/pull/1468)) ([`d9c4e4aa7`](https://github.com/block/buzz/commit/d9c4e4aa7fb5634f19d96f31da9f602951f503d0))
|
||||
- fix(profile): consolidate agent profile runtime metadata ([#1451](https://github.com/block/buzz/pull/1451)) ([`c48006fc3`](https://github.com/block/buzz/commit/c48006fc3e2e3a2a7984be1f02f6e6ba9d11b8eb))
|
||||
- fix(desktop): simplify workspace rail badges ([#1462](https://github.com/block/buzz/pull/1462)) ([`e42dae3f9`](https://github.com/block/buzz/commit/e42dae3f9ce767e832e1b954875e3bf3e662d35c))
|
||||
- perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots ([#1452](https://github.com/block/buzz/pull/1452)) ([`deb3e6adc`](https://github.com/block/buzz/commit/deb3e6adcaeb744439e794a71090d2d1dcfc004c))
|
||||
- perf(relay): bounded-concurrency multi-filter query execution (S2) ([#1457](https://github.com/block/buzz/pull/1457)) ([`a9e752e25`](https://github.com/block/buzz/commit/a9e752e2540a94d304a51ddeecbf68464ca9ec69))
|
||||
- fix(desktop): classify timeline prepends so history loads don't bump unread ([#1416](https://github.com/block/buzz/pull/1416)) ([`9967b97f5`](https://github.com/block/buzz/commit/9967b97f59179a0261ef5e2046df9632652be619))
|
||||
- fix(desktop): quiet gate for workspace switches instead of boot splash ([#1449](https://github.com/block/buzz/pull/1449)) ([`b779a3ee2`](https://github.com/block/buzz/commit/b779a3ee2f4fea3599d61511ae82fed439c919ce))
|
||||
- fix(read-path): reach complete threads, dense-second timelines, and all people in the GUI ([#1418](https://github.com/block/buzz/pull/1418)) ([`7da936fff`](https://github.com/block/buzz/commit/7da936fff82a9a956f338c690e9605888725ea3b))
|
||||
- E1+E3: reduce relay ingest/fan-out DB round trips; ack p99 −7–16%, fd p99 −6–28%, p999 tails −29–53% vs PR #1453 tip ([#1454](https://github.com/block/buzz/pull/1454)) ([`a504ad619`](https://github.com/block/buzz/commit/a504ad6197558575c0db7b9f53806d7337e0c64f))
|
||||
- perf(relay): defer post-commit dispatch and avoid verify clone ([#1453](https://github.com/block/buzz/pull/1453)) ([`7bd3760c8`](https://github.com/block/buzz/commit/7bd3760c82a6d640af199ed2301525877e629ced))
|
||||
- fix(relay): include git hook tools in runtime image ([#1326](https://github.com/block/buzz/pull/1326)) ([`88c089d3b`](https://github.com/block/buzz/commit/88c089d3b652bc952adbe8b32a6fc585121c982f))
|
||||
- feat(chart): per-pod emptyDir git scratch when persistence disabled (multi-replica HA) ([#1450](https://github.com/block/buzz/pull/1450)) ([`c88799ac6`](https://github.com/block/buzz/commit/c88799ac6c3b5b149196223abe7a6134c8823359))
|
||||
- fix(relay): remove media bearer-token auth ([#1444](https://github.com/block/buzz/pull/1444)) ([`0701f47f4`](https://github.com/block/buzz/commit/0701f47f4a31a904ebcd9f360cbd6aadaff9d784))
|
||||
- fix(desktop): stop search shortcut from hijacking the sidebar ([#1447](https://github.com/block/buzz/pull/1447)) ([`15ad7ae87`](https://github.com/block/buzz/commit/15ad7ae87e655a7873fa0b0a51f53b5f800afd9b))
|
||||
- fix(ci): set PGSCHEMA_PLAN_* in start-relay-for-tests.sh to avoid embedded-PG fetch ([#1443](https://github.com/block/buzz/pull/1443)) ([`89c4f7657`](https://github.com/block/buzz/commit/89c4f76579944ae1c7f86e37d76cecf2b378e60e))
|
||||
- feat(desktop): restore observer-feed regressions from #1381 and classify 4 new session/update types ([#1412](https://github.com/block/buzz/pull/1412)) ([`fec768436`](https://github.com/block/buzz/commit/fec76843665aeb4aff06688f83b93fc6ed2b603d))
|
||||
- fix(desktop): disable spellcheck/autocorrect/autocapitalize on emoji picker search ([#1438](https://github.com/block/buzz/pull/1438)) ([`bdeab23b5`](https://github.com/block/buzz/commit/bdeab23b59ca8e622e5c23b3368ecaccf21c5652))
|
||||
- feat(relay): add OpenTelemetry tracing, keep Prometheus metrics ([#1398](https://github.com/block/buzz/pull/1398)) ([`b1d9d955d`](https://github.com/block/buzz/commit/b1d9d955de83538c231a3034bf190af6df03070d))
|
||||
- feat(buzz-agent): emit agent_thought_chunk for reasoning content ([#1436](https://github.com/block/buzz/pull/1436)) ([`9f2a11b33`](https://github.com/block/buzz/commit/9f2a11b33827036d7b7415ede72c1dacd0fcd6f9))
|
||||
- feat(git): move repo-name registry to Postgres + relax RWM chart gate (HA relay) ([#1432](https://github.com/block/buzz/pull/1432)) ([`e5aa4a213`](https://github.com/block/buzz/commit/e5aa4a21327438c02fb25baea4d0849a498c9059))
|
||||
|
||||
|
||||
## v0.3.41
|
||||
|
||||
- Group consecutive desktop messages ([#1429](https://github.com/block/buzz/pull/1429)) ([`3d08c3b0`](https://github.com/block/buzz/commit/3d08c3b02b284d062e2932df8f96e2467bb40946))
|
||||
- Update Buzz app icon ([#1430](https://github.com/block/buzz/pull/1430)) ([`db2a9701`](https://github.com/block/buzz/commit/db2a97011536e810abdc3f813ec1867cbcef21f6))
|
||||
- feat(desktop): add workspace rail unread observer ([#1428](https://github.com/block/buzz/pull/1428)) ([`ce1f13e8`](https://github.com/block/buzz/commit/ce1f13e8f7a36621621d9249f7535a33439cfb87))
|
||||
- Prioritize channel members in mention autocomplete ([#1431](https://github.com/block/buzz/pull/1431)) ([`8fb33bdb`](https://github.com/block/buzz/commit/8fb33bdbca4285cac379ad18d42533859eacbd12))
|
||||
- Tighten message density ([#1426](https://github.com/block/buzz/pull/1426)) ([`6a08a3f4`](https://github.com/block/buzz/commit/6a08a3f4c8f79c2ed4e9ff7e425f3faa0b3627e5))
|
||||
- Tighten sidebar section actions ([#1424](https://github.com/block/buzz/pull/1424)) ([`c0e10d67`](https://github.com/block/buzz/commit/c0e10d67e06d405829ad8c4682b3cb621702d150))
|
||||
- Add memory copy actions ([#1427](https://github.com/block/buzz/pull/1427)) ([`6c0e6f0b`](https://github.com/block/buzz/commit/6c0e6f0bb74fe0b4777afbdb8151dddf7107c99f))
|
||||
- Limit agent profile quick actions ([#1425](https://github.com/block/buzz/pull/1425)) ([`177fe5a3`](https://github.com/block/buzz/commit/177fe5a3bfe7774205ca0a9de689924a5df988df))
|
||||
- Fade sidebar pinned chrome edges ([#1423](https://github.com/block/buzz/pull/1423)) ([`3e4e9dda`](https://github.com/block/buzz/commit/3e4e9dda94e04a67b6b73c372e853e961cca461d))
|
||||
- Apply smooth corners to inline media ([#1422](https://github.com/block/buzz/pull/1422)) ([`2fc8b9cf`](https://github.com/block/buzz/commit/2fc8b9cf5f14410750a97bbebc07241ce8628a1f))
|
||||
- Align thread summary rows ([#1421](https://github.com/block/buzz/pull/1421)) ([`4ae5a0d5`](https://github.com/block/buzz/commit/4ae5a0d5e372c5617aa32242c8e08c13a03d20b5))
|
||||
- Keep channel day dividers sticky ([#1420](https://github.com/block/buzz/pull/1420)) ([`8c388479`](https://github.com/block/buzz/commit/8c3884791361933f89e6771e1403e4799fc00c1d))
|
||||
- fix(deps): pin aws-creds to fork with EKS Pod Identity support ([#1419](https://github.com/block/buzz/pull/1419)) ([`86d6388e`](https://github.com/block/buzz/commit/86d6388e68d40aaa5449e3021e116a57cb2aefe0))
|
||||
- fix(relay): enable Redis TLS for rediss:// (ElastiCache) ([#1417](https://github.com/block/buzz/pull/1417)) ([`3292b502`](https://github.com/block/buzz/commit/3292b502aad44a5f849296d7bf28429bac272fb7))
|
||||
|
||||
|
||||
## v0.3.40
|
||||
|
||||
- fix(desktop): stabilize channel-timeline scrollback with per-row height reserves ([#1413](https://github.com/block/buzz/pull/1413)) ([`4fdc68f1`](https://github.com/block/buzz/commit/4fdc68f1568364b3e44d7003c83a9a4ad961e1ee))
|
||||
- fix(sidebar): trim working badge label and name working agents in tooltip ([#1408](https://github.com/block/buzz/pull/1408)) ([`697f63dd`](https://github.com/block/buzz/commit/697f63ddcc45df71e48a0c0ac81adada67a056e1))
|
||||
- Mobile tab bar polish ([#1368](https://github.com/block/buzz/pull/1368)) ([`c444e344`](https://github.com/block/buzz/commit/c444e3445bba4967246f790a296237cc597336a9))
|
||||
- feat(desktop): let thread pane expand on ultrawide monitors ([#1407](https://github.com/block/buzz/pull/1407)) ([`f86f97bb`](https://github.com/block/buzz/commit/f86f97bbde1e3ee914ef2d6ef385482f15a43d6a))
|
||||
|
||||
|
||||
## v0.3.39
|
||||
|
||||
- fix: close cross-process keychain race and namespace dev-build nest ([#1409](https://github.com/block/buzz/pull/1409)) ([`e8adc3383`](https://github.com/block/buzz/commit/e8adc3383fae3060124ed212d00538d543be0054))
|
||||
- feat(relay): allow agent owners to edit/manage agent-owned content ([#1403](https://github.com/block/buzz/pull/1403)) ([`0042c8e10`](https://github.com/block/buzz/commit/0042c8e106d952f408cf4afca052f7053a7c967e))
|
||||
- fix(media): support IRSA/credential-chain S3 auth and configurable signing region ([#1406](https://github.com/block/buzz/pull/1406)) ([`06ef533ec`](https://github.com/block/buzz/commit/06ef533ec7fec6cf7366f52d3b9fe2f83011bf24))
|
||||
- fix(desktop): fold baked build env into in-process model discovery ([#1376](https://github.com/block/buzz/pull/1376)) ([`f061ae9c8`](https://github.com/block/buzz/commit/f061ae9c879e95f650fb99347920763361d1fe22))
|
||||
- docs: link VISION_ACTIVITY from the VISION index ([#1405](https://github.com/block/buzz/pull/1405)) ([`aa1042b16`](https://github.com/block/buzz/commit/aa1042b1629466a94983d098e706a3905c645e9d))
|
||||
- test(desktop): gate keychain-write test behind --ignored ([#1404](https://github.com/block/buzz/pull/1404)) ([`36f32291e`](https://github.com/block/buzz/commit/36f32291e79fc2069b8ef4cae3c90915232f702a))
|
||||
- Refactor oversized mobile widgets ([#1401](https://github.com/block/buzz/pull/1401)) ([`71b2c41e9`](https://github.com/block/buzz/commit/71b2c41e927ca9c4aa2fd0f0c4662d4c8de6479a))
|
||||
- perf(desktop): stop beachballs on agents menu and thread open ([#1402](https://github.com/block/buzz/pull/1402)) ([`34d61acc9`](https://github.com/block/buzz/commit/34d61acc9545676bb33e1675de2ab9f226242a29))
|
||||
- feat(desktop): rebuild agent activity feed around a classifier registry and twelve render classes ([#1381](https://github.com/block/buzz/pull/1381)) ([`5cc09e698`](https://github.com/block/buzz/commit/5cc09e6989198df3918db8bc67e3bf020e6e0838))
|
||||
- fix(thread): stop mid-scroll content jump in live threads ([#1397](https://github.com/block/buzz/pull/1397)) ([`42ec17f13`](https://github.com/block/buzz/commit/42ec17f1375f8886cda817921addda2c3bf55fd9))
|
||||
- fix(ci): restore main to green — tauri fmt, personas.rs file-size split, Windows path test ([#1399](https://github.com/block/buzz/pull/1399)) ([`67c47de69`](https://github.com/block/buzz/commit/67c47de69c8a0028bcc35c1f5e9de489a8df9fb5))
|
||||
- fix(desktop): enable buzz-dev-mcp MCP server for Codex agents ([#1394](https://github.com/block/buzz/pull/1394)) ([`b74ed858f`](https://github.com/block/buzz/commit/b74ed858ff337b9db186215b9cbbe43c89d63132))
|
||||
- fix(ci): restore E2E flakiness fixes for pgschema, docker-pull, and spec timing ([#1396](https://github.com/block/buzz/pull/1396)) ([`fdf29c457`](https://github.com/block/buzz/commit/fdf29c457d0a4b10f99a703130786f71f724aa69))
|
||||
- fix(personas): persist pack-backed persona UI edits across reboot ([#1392](https://github.com/block/buzz/pull/1392)) ([`a7e1202cc`](https://github.com/block/buzz/commit/a7e1202cc545983d2fc1449dcca2d778d5c0f88d))
|
||||
- fix(buzz-acp): clear steer_rx on all run_prompt_task exit paths ([#1391](https://github.com/block/buzz/pull/1391)) ([`10aaa72f0`](https://github.com/block/buzz/commit/10aaa72f017b2d4cb1423de10c36718097fcb6c0))
|
||||
- Restore channel date divider rule ([#1395](https://github.com/block/buzz/pull/1395)) ([`0827171d5`](https://github.com/block/buzz/commit/0827171d5e1db6332bfb819fcf4d8441ac82be1e))
|
||||
- Speed up profile wave action ([#1379](https://github.com/block/buzz/pull/1379)) ([`2fc5dd35b`](https://github.com/block/buzz/commit/2fc5dd35b1cf48297613f7c7caba811f5248b431))
|
||||
- Restore visible links for rich previews ([#1378](https://github.com/block/buzz/pull/1378)) ([`8d8fc6331`](https://github.com/block/buzz/commit/8d8fc6331f11da2904c2798c56c7833c13dfa090))
|
||||
- Mobile channel list polish ([#1367](https://github.com/block/buzz/pull/1367)) ([`09d8965ab`](https://github.com/block/buzz/commit/09d8965ab686e5c910b7a04a20df58488f50a60c))
|
||||
- style(desktop): unify corner radii to rounded-2xl (16px) ([#1393](https://github.com/block/buzz/pull/1393)) ([`2f496debc`](https://github.com/block/buzz/commit/2f496debce4961c88a20d7c0b497ec99626413b2))
|
||||
- fix(desktop): skip keychain write when blob contents are unchanged ([#1377](https://github.com/block/buzz/pull/1377)) ([`5a64ee4a6`](https://github.com/block/buzz/commit/5a64ee4a69d46d9cef816a04c14b9abc9313eaa6))
|
||||
- fix(desktop): stop clipping the agent-activity row under the composer ([#1371](https://github.com/block/buzz/pull/1371)) ([`2044ad76f`](https://github.com/block/buzz/commit/2044ad76f3dfd9f260a48cef176bed3b054f637d))
|
||||
- Constrain macOS overscroll to conversations ([#1317](https://github.com/block/buzz/pull/1317)) ([`9b711f47a`](https://github.com/block/buzz/commit/9b711f47a791810a7c46069648d1a280b9739c6e))
|
||||
- Mobile appearance foundation ([#1366](https://github.com/block/buzz/pull/1366)) ([`945e9b879`](https://github.com/block/buzz/commit/945e9b879ae2935312698a1e913cfb3009f8c10d))
|
||||
|
||||
|
||||
## v0.3.38
|
||||
|
||||
- feat(desktop): provider-agnostic model selection + databricks discovery ([#1307](https://github.com/block/buzz/pull/1307)) ([`eacbbe880`](https://github.com/block/buzz/commit/eacbbe880a50acf400ff7c162b5bc8705ab0063f))
|
||||
- release(helm): buzz chart 0.1.1 ([#1374](https://github.com/block/buzz/pull/1374)) ([`2561cbd06`](https://github.com/block/buzz/commit/2561cbd069a4f7a0ca4824f780867aa30ea9f744))
|
||||
- Harden relay attack surfaces ([#1369](https://github.com/block/buzz/pull/1369)) ([`29368cf17`](https://github.com/block/buzz/commit/29368cf17b7d5924fe571512b2194e3f48b21a16))
|
||||
- ci(helm): publish chart to GHCR on chart-v* tags ([#1372](https://github.com/block/buzz/pull/1372)) ([`2722ce422`](https://github.com/block/buzz/commit/2722ce4226838272ab36dc8630feacd2a90e1775))
|
||||
- feat(buzz-agent): add databricks_v2 provider for AI Gateway v2 ([#1311](https://github.com/block/buzz/pull/1311)) ([`15a73aa27`](https://github.com/block/buzz/commit/15a73aa27feb9db72c9535a8b1113189d2be8dd4))
|
||||
- refactor(desktop): centralize auxiliary panel shell ([#1343](https://github.com/block/buzz/pull/1343)) ([`e6738c501`](https://github.com/block/buzz/commit/e6738c50153cdb3f78448c912a2e8cd660da5779))
|
||||
- perf(desktop): stop typing from re-rendering the channel pane + timeline ([#1364](https://github.com/block/buzz/pull/1364)) ([`db1b617ab`](https://github.com/block/buzz/commit/db1b617ab12792b6c5d90d988e52b9a86d7aa361))
|
||||
- fix(desktop): check PGID in orphan sweep and signal correct process groups ([#1359](https://github.com/block/buzz/pull/1359)) ([`59be27ff3`](https://github.com/block/buzz/commit/59be27ff3fa4c0b0b95688565e945f6f5e0d60ef))
|
||||
- feat(desktop): harness-agnostic config bridge ([#887](https://github.com/block/buzz/pull/887)) ([`c65989a61`](https://github.com/block/buzz/commit/c65989a61bba7050e4fbc1798630f96d541ab8db))
|
||||
- fix(acp): enable sandbox network access for Codex MCP subprocesses ([#1363](https://github.com/block/buzz/pull/1363)) ([`401bb51bf`](https://github.com/block/buzz/commit/401bb51bf2dd64776a1862575548cb25ab41ff70))
|
||||
- perf(desktop): don't block channel create on channel-list refetch ([#1360](https://github.com/block/buzz/pull/1360)) ([`cf57bcbea`](https://github.com/block/buzz/commit/cf57bcbea4af355774d499e109238a5d527ae39e))
|
||||
- refactor(desktop): split global styles and markdown renderers ([#1361](https://github.com/block/buzz/pull/1361)) ([`c27b4251a`](https://github.com/block/buzz/commit/c27b4251a9304911f2359a3ee22b6f26b13af78b))
|
||||
- feat(read-state): multi-slot splitting + no-op suppression for oversized blobs ([#1309](https://github.com/block/buzz/pull/1309)) ([`2612324fd`](https://github.com/block/buzz/commit/2612324fd935b3f19ef78e07885aa05f7ef907f1))
|
||||
- fix: unconditionally replace CLI symlink on boot ([#1357](https://github.com/block/buzz/pull/1357)) ([`50873ef98`](https://github.com/block/buzz/commit/50873ef98d9080614720cd770781698c9b16313d))
|
||||
|
||||
|
||||
## v0.3.37
|
||||
|
||||
- feat(buzz-acp): steering as the default mid-turn mention delivery ([#1160](https://github.com/block/buzz/pull/1160)) ([`e567491a`](https://github.com/block/buzz/commit/e567491a196396658ef4c1d4ff6128efd8b2744e))
|
||||
- Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) ([#1321](https://github.com/block/buzz/pull/1321)) ([`14fba21e`](https://github.com/block/buzz/commit/14fba21e57b8d671ebbea473226be52a5f2ae636))
|
||||
- Disable persona start while runtime discovery runs ([#1353](https://github.com/block/buzz/pull/1353)) ([`92a5f1fc`](https://github.com/block/buzz/commit/92a5f1fc6624066f391bd5c57e9bb433df613b56))
|
||||
- chore(deps): update dependency @tanstack/react-virtual to v3.14.4 ([#1342](https://github.com/block/buzz/pull/1342)) ([`1c221eaa`](https://github.com/block/buzz/commit/1c221eaafd1c8537532c3c8ce76c502330b00bf7))
|
||||
- Fix sidebar unread indicator placement ([#1319](https://github.com/block/buzz/pull/1319)) ([`51b2613c`](https://github.com/block/buzz/commit/51b2613c0e42327d9ba0352c3bb3be7e7c4737a6))
|
||||
- fix(desktop): un-clip hover action bar's upward bleed under content-visibility ([#1354](https://github.com/block/buzz/pull/1354)) ([`7adbd05b`](https://github.com/block/buzz/commit/7adbd05b45db3db0abb7cc1f5eb0dc7248d47711))
|
||||
- Allow Huddle between 2 humans in DM ([#1347](https://github.com/block/buzz/pull/1347)) ([`b58f671b`](https://github.com/block/buzz/commit/b58f671b4ee3d9f6a8fd51ca23f0dc8b8036ed36))
|
||||
|
||||
|
||||
## v0.3.36
|
||||
|
||||
- Polish agent runtime cards ([#1327](https://github.com/block/buzz/pull/1327)) ([`a3c4f3f6`](https://github.com/block/buzz/commit/a3c4f3f625a23addb6053c12f11f79f583c394c1))
|
||||
- Rework desktop message-timeline scrolling: de-virtualize + native overflow-anchor ([#1338](https://github.com/block/buzz/pull/1338)) ([`4d619693`](https://github.com/block/buzz/commit/4d6196934e1f082ff5f16277edd33e3108aee38f))
|
||||
- Keep wave huddles pending for placeholder profiles ([#1349](https://github.com/block/buzz/pull/1349)) ([`c1d6f3f2`](https://github.com/block/buzz/commit/c1d6f3f291b0f419bf0666f258d4689041747327))
|
||||
- Polish profile hover cards ([#1346](https://github.com/block/buzz/pull/1346)) ([`e3fc0e02`](https://github.com/block/buzz/commit/e3fc0e0278d76b545adb6e7921387da9eeb7fdc3))
|
||||
- Fix channel header shared blur layering ([#1336](https://github.com/block/buzz/pull/1336)) ([`360d2e54`](https://github.com/block/buzz/commit/360d2e54409eec21ec59a31c9a12d9d7821506a3))
|
||||
- Add rich link previews ([#1334](https://github.com/block/buzz/pull/1334)) ([`bc925780`](https://github.com/block/buzz/commit/bc925780eed2169dd6dba9a2c9f85d05e2c9de1b))
|
||||
- Revamp new agent dialog ([#1201](https://github.com/block/buzz/pull/1201)) ([`826d735f`](https://github.com/block/buzz/commit/826d735fe6712be820616d4cb1d6228cfe3be47e))
|
||||
- Image attachment gallery lightbox ([#1345](https://github.com/block/buzz/pull/1345)) ([`7d3ee683`](https://github.com/block/buzz/commit/7d3ee6833665a4370db51b1ce0fa13f7174d9279))
|
||||
- Reset prevent-sleep cap on agent activity ([#1335](https://github.com/block/buzz/pull/1335)) ([`433b1794`](https://github.com/block/buzz/commit/433b1794d55b568f8a1331d18536d136a1a03463))
|
||||
- docs: update sprout repository references and document buzz mem ([#1333](https://github.com/block/buzz/pull/1333)) ([`52b6365e`](https://github.com/block/buzz/commit/52b6365e17529f385864433210985813023476ff))
|
||||
- fix(buzz-agent): charge images a token-equivalent for the handoff gate ([#1332](https://github.com/block/buzz/pull/1332)) ([`744c77bc`](https://github.com/block/buzz/commit/744c77bc2d2db7d57a07a7701042db73ca40faa2))
|
||||
- Polish thread reply hover states ([#1329](https://github.com/block/buzz/pull/1329)) ([`aca40b62`](https://github.com/block/buzz/commit/aca40b62899248359c2587efaae80c44009b87bb))
|
||||
- Add persona and team import polish ([#1203](https://github.com/block/buzz/pull/1203)) ([`f00c86e7`](https://github.com/block/buzz/commit/f00c86e7446b6addfc83870f7d0571373642afd4))
|
||||
- fix(desktop): reserve PTT shortcut only during active huddle ([#1315](https://github.com/block/buzz/pull/1315)) ([`6c60cb59`](https://github.com/block/buzz/commit/6c60cb59abb3e086281dd470390ba7e87bc5ab25))
|
||||
|
||||
|
||||
## v0.3.35
|
||||
|
||||
- fix(desktop): split lib modules under size guard ([#1314](https://github.com/block/buzz/pull/1314)) ([`e7d43dc2`](https://github.com/block/buzz/commit/e7d43dc2253f0d1efe7689ac82a8b6b4a7788fd0))
|
||||
- Fix desktop notifications on GNOME 46+ Linux ([#1246](https://github.com/block/buzz/pull/1246)) ([`ca50d832`](https://github.com/block/buzz/commit/ca50d832892f6203a59eeaadf3fe7b7e8b8e9888))
|
||||
- perf(desktop): debounce channel-list refetch + profile get_channels ([#1310](https://github.com/block/buzz/pull/1310)) ([`c6e3e947`](https://github.com/block/buzz/commit/c6e3e947abc2592eea08c7601adc76afe0c14c95))
|
||||
- feat(desktop): move agent management into profile sidebar ([#1274](https://github.com/block/buzz/pull/1274)) ([`8d40150c`](https://github.com/block/buzz/commit/8d40150c8377df08dbaed61de987ca69d45d15e0))
|
||||
- feat(desktop): re-land virtualized timeline to fix macOS beachball ([#1250](https://github.com/block/buzz/pull/1250)) ([`8c3d0c92`](https://github.com/block/buzz/commit/8c3d0c92e83f1b482dbd93f2bd0d307988c91788))
|
||||
- feat(acp): add BUZZ_ACP_ALLOWED_RESPOND_TO and BUZZ_ALLOWED_CHANNEL_ADD_POLICIES gates ([#1304](https://github.com/block/buzz/pull/1304)) ([`1a61d783`](https://github.com/block/buzz/commit/1a61d783ad072eefd03bb070be66ec2cf889dbde))
|
||||
- fix(read-state): enforce byte-budget eviction in currentContexts() ([#1305](https://github.com/block/buzz/pull/1305)) ([`6b056461`](https://github.com/block/buzz/commit/6b0564618bafd3737778511b291e3b80ab7fc43e))
|
||||
- feat(media): transcode HEIC/HEIF to JPEG on desktop upload ([#1257](https://github.com/block/buzz/pull/1257)) ([`d32f3c0a`](https://github.com/block/buzz/commit/d32f3c0a58dc3a3ce52b72cc0a8899228f17d69e))
|
||||
- Bring mobile sidebar unread and DM parity ([#1303](https://github.com/block/buzz/pull/1303)) ([`1843a057`](https://github.com/block/buzz/commit/1843a057477129e392518dcc62a9814717a67c0b))
|
||||
- Multi-tenant relay: spec + mechanized formal proof (S1–S8) ([#1285](https://github.com/block/buzz/pull/1285)) ([`2ecdcce7`](https://github.com/block/buzz/commit/2ecdcce7bdb5471cddf79cb5d4ce486a75ed2fda))
|
||||
- Polish side panel motion and composer alignment ([#1294](https://github.com/block/buzz/pull/1294)) ([`34b2d3e8`](https://github.com/block/buzz/commit/34b2d3e8b43af132111e878288f1c4b4aa7777dd))
|
||||
- feat(mobile): harden unread badges and float tabs ([#1298](https://github.com/block/buzz/pull/1298)) ([`59b21592`](https://github.com/block/buzz/commit/59b21592c92d55f6bd13524697f9cef257d9e4fe))
|
||||
- feat(desktop): restore archive identity UI in profile panel ([#961](https://github.com/block/buzz/pull/961)) ([`4fce5aab`](https://github.com/block/buzz/commit/4fce5aab2e09a16a954cf6b685b7e2c1e897bd27))
|
||||
- fix(sidebar): non-selectable channel names + copy/leave context menu actions ([#1260](https://github.com/block/buzz/pull/1260)) ([`4481f8fd`](https://github.com/block/buzz/commit/4481f8fd5a9cd8cd5a482a78ed63e2a34e600066))
|
||||
- fix(runtime): sweep node wrapper processes hosting managed agent shims ([#1296](https://github.com/block/buzz/pull/1296)) ([`f072032a`](https://github.com/block/buzz/commit/f072032aaf52e216eecf652cd8625367c8c943ce))
|
||||
- fix(buzz-agent): follow symlinks when discovering skill directories ([#1295](https://github.com/block/buzz/pull/1295)) ([`8717ddf2`](https://github.com/block/buzz/commit/8717ddf2ebc8cc6324996f9e5c16b596d1febc71))
|
||||
- chore: add grab-emoji.sh to register Slack emoji in Buzz ([#1292](https://github.com/block/buzz/pull/1292)) ([`dfa864fc`](https://github.com/block/buzz/commit/dfa864fc457baa66dc9f4b4f501cccdd0d219e55))
|
||||
- Fix cross-pod membership notification fanout ([#1291](https://github.com/block/buzz/pull/1291)) ([`e1c51d71`](https://github.com/block/buzz/commit/e1c51d71d48f9ad0a08c3e7e0af35929a0234ea1))
|
||||
- fix(buzz-acp): strengthen agent communication rules in base prompt ([#1293](https://github.com/block/buzz/pull/1293)) ([`ccdb8975`](https://github.com/block/buzz/commit/ccdb8975d3683cd4710b52443284025aa6d05cd9))
|
||||
|
||||
|
||||
## v0.3.34
|
||||
|
||||
- feat(desktop): refresh Agents tab live on inbound relay sync ([#1256](https://github.com/block/buzz/pull/1256)) ([`9a59e308`](https://github.com/block/buzz/commit/9a59e30817c642b1e6aab296863570869e215a19))
|
||||
- fix(buzz-acp): inject Codex network allowlist for relay hostname at spawn time ([#1287](https://github.com/block/buzz/pull/1287)) ([`0379247e`](https://github.com/block/buzz/commit/0379247eba8a6c7e48a94f3fb8a329e7bdbca13c))
|
||||
- refactor(desktop): consolidate notification helpers and add channel names to toasts ([#1286](https://github.com/block/buzz/pull/1286)) ([`dd5592e3`](https://github.com/block/buzz/commit/dd5592e34fcd7690340633dcec368db485557294))
|
||||
- feat(buzz-agent): lazy skill loading via load_skill tool ([#1283](https://github.com/block/buzz/pull/1283)) ([`25396c06`](https://github.com/block/buzz/commit/25396c06d65c5e9a6d1271559cde4230e06ff35d))
|
||||
- fix(buzz-acp): human-aware reply anchoring to keep threads flat ([#1281](https://github.com/block/buzz/pull/1281)) ([`6c920d21`](https://github.com/block/buzz/commit/6c920d21a852d301effb8a667d824e6b96485a7d))
|
||||
- feat(desktop): add 'Follow system' theme mode ([#1262](https://github.com/block/buzz/pull/1262)) ([`c996be39`](https://github.com/block/buzz/commit/c996be395e23e50e5097f088738b3de9176759d4))
|
||||
- Fix mention autocomplete layout in narrow threads ([#1282](https://github.com/block/buzz/pull/1282)) ([`227518c2`](https://github.com/block/buzz/commit/227518c234175440f9cd3734d01b96bbbbc89065))
|
||||
- Clean up screenshot e2e tests ([#1284](https://github.com/block/buzz/pull/1284)) ([`2499d339`](https://github.com/block/buzz/commit/2499d33971524774f07f5186dadc8b33e055d812))
|
||||
- fix(desktop): DM close button replaces unread badge on hover ([#1280](https://github.com/block/buzz/pull/1280)) ([`e3663721`](https://github.com/block/buzz/commit/e3663721830d79df6562a0b3ef78c8fe6bc1b7c3))
|
||||
- Add NIP-34 git pull request CLI support ([#1279](https://github.com/block/buzz/pull/1279)) ([`5fef9b72`](https://github.com/block/buzz/commit/5fef9b727fb8856d45840ccc97d8ec820a48f6e0))
|
||||
- fix(desktop): preserve timeline scroll when opening threads ([#1278](https://github.com/block/buzz/pull/1278)) ([`d05f122d`](https://github.com/block/buzz/commit/d05f122d8acf49e924fe11992dde4bde41dee3f7))
|
||||
- chore: remove LLM-slop comments across the codebase ([#1277](https://github.com/block/buzz/pull/1277)) ([`73cc31cc`](https://github.com/block/buzz/commit/73cc31cc528318debedd38e85d67b79d1feb55e8))
|
||||
- fix(desktop): allow saving personas with an empty system prompt ([#1276](https://github.com/block/buzz/pull/1276)) ([`996a3f89`](https://github.com/block/buzz/commit/996a3f89d5f3835009236782f2b3a93409b090cf))
|
||||
|
||||
|
||||
## v0.3.33
|
||||
|
||||
- fix(desktop): always use legacy keyring for blob entry on macOS ([#1271](https://github.com/block/buzz/pull/1271)) ([`e7c3638fe`](https://github.com/block/buzz/commit/e7c3638fe984e7785a6e51e547b5844e894ed013))
|
||||
- chore(release): release Buzz Relay version 0.1.1 ([#1269](https://github.com/block/buzz/pull/1269)) ([`68a0cc850`](https://github.com/block/buzz/commit/68a0cc8506be4ea1fb110b65eec0787e4cd84378))
|
||||
- perf(desktop): consolidate keychain secrets into a single blob entry ([#1267](https://github.com/block/buzz/pull/1267)) ([`fa942cb51`](https://github.com/block/buzz/commit/fa942cb51d0714cba54747bcc7197c14e770c338))
|
||||
- feat(desktop): re-snapshot persona config on every agent spawn ([#1268](https://github.com/block/buzz/pull/1268)) ([`048e8fdc0`](https://github.com/block/buzz/commit/048e8fdc004322e5feccc4fd76c8fc0dc87d91f0))
|
||||
- feat(relay): add buzz-admin member management CLI with NIP-43 roster publish ([#1265](https://github.com/block/buzz/pull/1265)) ([`0cee0435f`](https://github.com/block/buzz/commit/0cee0435f7f955a42d7824f1967e4f4fc6a02f74))
|
||||
- fix(desktop): fall back to old keychain when DPK unavailable (unsigned builds) ([#1266](https://github.com/block/buzz/pull/1266)) ([`958ac7aaa`](https://github.com/block/buzz/commit/958ac7aaaae90da76c864af744242f29ee4f08de))
|
||||
- Align channel management panel with profile ([#1066](https://github.com/block/buzz/pull/1066)) ([`9f35b0188`](https://github.com/block/buzz/commit/9f35b018803f56b2330bfb79de4ab19f18d80256))
|
||||
- fix(relay): multi-pod subscription coherence (one access-gated fan-out path + cross-pod cache invalidation + REQ/COUNT DB guard) ([#1261](https://github.com/block/buzz/pull/1261)) ([`628445429`](https://github.com/block/buzz/commit/6284454298cb5be72cefbeaf13abed04afe3cc9e))
|
||||
- fix(desktop): switch macOS keychain to Data Protection Keychain ([#1264](https://github.com/block/buzz/pull/1264)) ([`35522311a`](https://github.com/block/buzz/commit/35522311a963b54f32df864d338ab7d75102247f))
|
||||
- fix(desktop): use IPv4 loopback for media proxy URLs ([#1245](https://github.com/block/buzz/pull/1245)) ([`cee2c5f26`](https://github.com/block/buzz/commit/cee2c5f2634ae7d15262a2ec07cccacc8460cd0e))
|
||||
- perf(relay/git): stream read-path, manifest info/refs fast path, idx sidecar (A/B/C) ([#1240](https://github.com/block/buzz/pull/1240)) ([`856815994`](https://github.com/block/buzz/commit/856815994c3441268eb48d2264aeb3492b701fe5))
|
||||
|
||||
|
||||
## v0.3.32
|
||||
|
||||
- fix(desktop): restore solid dot for top-level channel unreads ([#1253](https://github.com/block/buzz/pull/1253)) ([`9d3bfd38b`](https://github.com/block/buzz/commit/9d3bfd38b0876cd1bdf5bb90d6639f200ceb12e0))
|
||||
- Allow explicit root posts from reply prompts ([#1251](https://github.com/block/buzz/pull/1251)) ([`ebd74d11b`](https://github.com/block/buzz/commit/ebd74d11b4e77195fa94158205787e6e5edca3ff))
|
||||
- feat: publish personas, teams, and managed agents as relay events ([#939](https://github.com/block/buzz/pull/939)) ([`d256935c3`](https://github.com/block/buzz/commit/d256935c3620f30ae54e9d02e48adc6ee0bc578f))
|
||||
- fix(observability): gate agent observability on declared ownership, not key custody ([#1229](https://github.com/block/buzz/pull/1229)) ([`e993b9e74`](https://github.com/block/buzz/commit/e993b9e741649f73c24f9afbb01420cffc822e5a))
|
||||
- fix(desktop): split AppShell notification effects ([#1248](https://github.com/block/buzz/pull/1248)) ([`a9ce477a0`](https://github.com/block/buzz/commit/a9ce477a041c7f201b1f7c0b79c68d7b0aa3f38e))
|
||||
- feat(desktop): store nsec private keys in the OS keyring ([#1172](https://github.com/block/buzz/pull/1172)) ([`4be43d708`](https://github.com/block/buzz/commit/4be43d7086753e843884f5f541c3a8cbf1512fff))
|
||||
- fix: add evict-completed-work prompt rule and unblock desktop file-size gate ([#1247](https://github.com/block/buzz/pull/1247)) ([`048228889`](https://github.com/block/buzz/commit/048228889122160ce73f9e32aba36961613e984e))
|
||||
- fix(desktop): scope mention/add autocomplete to reachable identities ([#1243](https://github.com/block/buzz/pull/1243)) ([`a3e8fb6fd`](https://github.com/block/buzz/commit/a3e8fb6fd512f34d53b7ed36e68ab7f8bfa01291))
|
||||
- test(e2e): raise scroll-history pagination test timeout to 90s ([#1234](https://github.com/block/buzz/pull/1234)) ([`09dbeb86f`](https://github.com/block/buzz/commit/09dbeb86fa8b6f430c79adf7c585194e2ec0a3d0))
|
||||
- Polish thread and media layout ([#1239](https://github.com/block/buzz/pull/1239)) ([`b264a32cf`](https://github.com/block/buzz/commit/b264a32cfbdc8481e9dd4ceadbb05990bd7af02a))
|
||||
- Polish desktop navigation chrome ([#1238](https://github.com/block/buzz/pull/1238)) ([`092546495`](https://github.com/block/buzz/commit/092546495d73d305e4d0226ee14bf0d5de4d2f53))
|
||||
- fix: propagate persona harness edits to live agent instances ([#1244](https://github.com/block/buzz/pull/1244)) ([`2e426b2fd`](https://github.com/block/buzz/commit/2e426b2fddbbec1f1261125c81d83cd57e838fd1))
|
||||
- fix(desktop): render autolinked message links as chips ([#1241](https://github.com/block/buzz/pull/1241)) ([`45067ec13`](https://github.com/block/buzz/commit/45067ec13507c406ee41d3f184fc96c988b1348b))
|
||||
- fix(desktop): clear unread projections from message read markers ([#1242](https://github.com/block/buzz/pull/1242)) ([`733c766eb`](https://github.com/block/buzz/commit/733c766eba90733a00dc397dd2ab9706935e5183))
|
||||
- fix(desktop): resolve repos_dir symlink at boot before agent restore ([#1231](https://github.com/block/buzz/pull/1231)) ([`86d7748eb`](https://github.com/block/buzz/commit/86d7748ebd93027754bfd2326958c4324aa817d6))
|
||||
- Fix presence fan-out across relay pods ([#1227](https://github.com/block/buzz/pull/1227)) ([`36d3d2ed1`](https://github.com/block/buzz/commit/36d3d2ed1e57e399d5221edfd109082fd8a4d73e))
|
||||
- fix(relay): raise grace limit, add replay backpressure, and NOTICE on oversized frames ([#1226](https://github.com/block/buzz/pull/1226)) ([`142a5c909`](https://github.com/block/buzz/commit/142a5c909542f1e1d2119ca4562129d492aca94c))
|
||||
- [codex] Make relay frame limit configurable ([#1225](https://github.com/block/buzz/pull/1225)) ([`38a953343`](https://github.com/block/buzz/commit/38a95334396dae0e271a478b46baa62d29deebff))
|
||||
|
||||
|
||||
## v0.3.31
|
||||
|
||||
- fix(release): publish versioned relay Docker tags via independent release lanes ([#1173](https://github.com/block/buzz/pull/1173)) ([`549b7d24`](https://github.com/block/buzz/commit/549b7d24813320045bdda629d865c6c7418e7450))
|
||||
- fix(desktop): align settings section headers ([#1165](https://github.com/block/buzz/pull/1165)) ([`6ad68a6b`](https://github.com/block/buzz/commit/6ad68a6b095cd5db328ae07dfac4013eeda5820a))
|
||||
- fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir ([#1194](https://github.com/block/buzz/pull/1194)) ([`1011cea2`](https://github.com/block/buzz/commit/1011cea2682a5e8cd92c9da255d5ba6c8f7ced78))
|
||||
- fix(desktop): enable mesh llm for release builds ([#1221](https://github.com/block/buzz/pull/1221)) ([`fa1262a9`](https://github.com/block/buzz/commit/fa1262a92c85b96c1d643c247d28df4f2f57e81a))
|
||||
- fix(desktop): move crypto commands off the main thread ([#1222](https://github.com/block/buzz/pull/1222)) ([`e35e84b0`](https://github.com/block/buzz/commit/e35e84b08bdc44f1f5297e6957bcc52e7c31eb70))
|
||||
- fix: tolerate missing private_key_nsec in agent store ([#1220](https://github.com/block/buzz/pull/1220)) ([`c58e9880`](https://github.com/block/buzz/commit/c58e9880bf60324b0fbb64917b6d1c8e197d4ea4))
|
||||
- Update navigation header height ([#1212](https://github.com/block/buzz/pull/1212)) ([`6b5cf325`](https://github.com/block/buzz/commit/6b5cf325c2777f64696923cf5b1c1ffd4fdf82e2))
|
||||
- Improve global search ([#1195](https://github.com/block/buzz/pull/1195)) ([`5130a6a0`](https://github.com/block/buzz/commit/5130a6a0b60c56a5c31549d3d4e85b956e35a671))
|
||||
- Parse Typesense multi_search errors ([#1208](https://github.com/block/buzz/pull/1208)) ([`65ccb126`](https://github.com/block/buzz/commit/65ccb1262fb876b74584bca1165feef39eda67a6))
|
||||
- fix(desktop): keep settings shortcut from opening search ([#1204](https://github.com/block/buzz/pull/1204)) ([`89ff9504`](https://github.com/block/buzz/commit/89ff950444d03ea09eb54661a21f0cb96f0bfcb6))
|
||||
- Polish sidebar channel navigation ([#1213](https://github.com/block/buzz/pull/1213)) ([`c0a872e8`](https://github.com/block/buzz/commit/c0a872e898479bcb2c3dda1b642c0d1373174f68))
|
||||
- fix(desktop): restore channel unread badges ([#1218](https://github.com/block/buzz/pull/1218)) ([`89aaa264`](https://github.com/block/buzz/commit/89aaa26443486244b6f004a7c419f4e0dc86aa44))
|
||||
- Fix collapsed home header chrome overlap ([#1215](https://github.com/block/buzz/pull/1215)) ([`b4e75a1e`](https://github.com/block/buzz/commit/b4e75a1e41a614fa3449e814ccbd9f31090dfbfc))
|
||||
- fix(desktop): dedupe welcome intro per channel ([#1216](https://github.com/block/buzz/pull/1216)) ([`2a522826`](https://github.com/block/buzz/commit/2a522826edc6dfb4df79f34256beea6b8597505b))
|
||||
- fix(desktop): defer agent page secondary requests ([#1217](https://github.com/block/buzz/pull/1217)) ([`bee2d64c`](https://github.com/block/buzz/commit/bee2d64cf7f093088cc28463e96a6c94b64f280e))
|
||||
- ci(release): enable Tauri auto-updater on Windows and Linux builds ([#1206](https://github.com/block/buzz/pull/1206)) ([`3ef2a8e5`](https://github.com/block/buzz/commit/3ef2a8e5c7e655f3347931135dde5f65b919c915))
|
||||
- Hydrate reactions for rendered messages ([#1205](https://github.com/block/buzz/pull/1205)) ([`ed556f3d`](https://github.com/block/buzz/commit/ed556f3deb895e0adfa18274b3ed90f255b5f6ad))
|
||||
- fix(desktop): show NIP-OA owners in profile pane ([#1198](https://github.com/block/buzz/pull/1198)) ([`40070a58`](https://github.com/block/buzz/commit/40070a58559938ed649950ccabce0725dd3c966e))
|
||||
- fix(desktop): preserve login-shell PATH for managed agents ([#1193](https://github.com/block/buzz/pull/1193)) ([`29978b6f`](https://github.com/block/buzz/commit/29978b6f93cdd2c5d061093ddce87d567d8d4c17))
|
||||
- Fix nav chrome offset in fullscreen ([#1192](https://github.com/block/buzz/pull/1192)) ([`b3b0704e`](https://github.com/block/buzz/commit/b3b0704efb5afc74dca0a093a6e4594973e9edf4))
|
||||
- fix(desktop): show due-reminder count in the Inbox nav badge ([#1191](https://github.com/block/buzz/pull/1191)) ([`c0858dac`](https://github.com/block/buzz/commit/c0858dac12a3efd09d301f5424074f18df5cf422))
|
||||
|
||||
|
||||
## v0.3.30
|
||||
|
||||
- fix(desktop): collapse mark-read/unread menu into one toggling item ([#1188](https://github.com/block/buzz/pull/1188)) ([`ce994df74`](https://github.com/block/buzz/commit/ce994df74e60cf43b2fb0b97ea9989aacd47650e))
|
||||
- fix(channels): replace thread-unread badge frontier with per-message read markers ([#1178](https://github.com/block/buzz/pull/1178)) ([`28db41dfd`](https://github.com/block/buzz/commit/28db41dfd383f3f7acd57011bd98661a08e7bfe4))
|
||||
- fix(relay): resubscribe agents when an archived channel is restored ([#1187](https://github.com/block/buzz/pull/1187)) ([`6780ea21e`](https://github.com/block/buzz/commit/6780ea21e44a525aa73455c4ae79702564f783f2))
|
||||
|
||||
|
||||
## v0.3.29
|
||||
|
||||
- build: make mesh-llm opt-in for dev/staging to speed up iteration ([#1183](https://github.com/block/buzz/pull/1183)) ([`ce526106`](https://github.com/block/buzz/commit/ce5261067d2b970ecca7e0d6c24479fcd2ccad43))
|
||||
- feat(desktop): default temporary channels to 7-day expiry ([#1182](https://github.com/block/buzz/pull/1182)) ([`4b518fc4`](https://github.com/block/buzz/commit/4b518fc4b3ec6c55eeb4d4b203057d55d8ba0c56))
|
||||
- fix(desktop): render lightbox image sharply at full resolution ([#1181](https://github.com/block/buzz/pull/1181)) ([`ff727af9`](https://github.com/block/buzz/commit/ff727af926ce5a7a840b042d6cff4b5a40a81db5))
|
||||
- fix(desktop): keep thread guides behind the reply composer ([#1179](https://github.com/block/buzz/pull/1179)) ([`781d1acf`](https://github.com/block/buzz/commit/781d1acf8a412df882fbd49ae1dd2f31a1a3096f))
|
||||
- Sort direct messages alphabetically ([#1180](https://github.com/block/buzz/pull/1180)) ([`04df2c8d`](https://github.com/block/buzz/commit/04df2c8dc6ab055a5a9360f4a492715af6d28dc1))
|
||||
- Add composer selection formatting tray ([#1133](https://github.com/block/buzz/pull/1133)) ([`7dcf22d5`](https://github.com/block/buzz/commit/7dcf22d50eb4c7503248caa95398f889e6290839))
|
||||
- fix(desktop): honor per-agent relay override, default to workspace relay ([#1131](https://github.com/block/buzz/pull/1131)) ([`ea97e219`](https://github.com/block/buzz/commit/ea97e219895e396708cfc626d64b21f864a2674e))
|
||||
- [codex] Stabilize desktop E2E flakes ([#1174](https://github.com/block/buzz/pull/1174)) ([`141f7118`](https://github.com/block/buzz/commit/141f7118ae6aedd910966651c53d92f9c6f71702))
|
||||
- feat(desktop): show reminder author + source and click-to-navigate in inbox ([#1176](https://github.com/block/buzz/pull/1176)) ([`1186ea48`](https://github.com/block/buzz/commit/1186ea48b99cf2d2cd789137b3e907cee5d6e16c))
|
||||
- Improve inbox thread updates ([#1114](https://github.com/block/buzz/pull/1114)) ([`de3f21ab`](https://github.com/block/buzz/commit/de3f21abcd60985c47f43c34d963a68300018f0b))
|
||||
- docs(readme): mark git hosting as shipped in status table ([#1175](https://github.com/block/buzz/pull/1175)) ([`26fdfb67`](https://github.com/block/buzz/commit/26fdfb674c387fca20637ee4c2169f20fb98e50b))
|
||||
- feat(windows): bundle full Git-for-Windows toolchain for the shell tool ([#1145](https://github.com/block/buzz/pull/1145)) ([`592508ad`](https://github.com/block/buzz/commit/592508adddd889d1b2bbfd8b5384fc2b7107c35c))
|
||||
- fix(buzz-dev-mcp): resolve MSYS-absolute paths in the Windows file tools ([#1169](https://github.com/block/buzz/pull/1169)) ([`ec5cfa37`](https://github.com/block/buzz/commit/ec5cfa3796d88b6ea92c620817163e5c10fad8b3))
|
||||
|
||||
|
||||
## v0.3.28
|
||||
|
||||
- Improve thread branch display ([#1166](https://github.com/block/buzz/pull/1166)) ([`342a251a`](https://github.com/block/buzz/commit/342a251a9199b55ffc0e19cb50ee7e09a8ec051f))
|
||||
- Copy channel name from header ([#1157](https://github.com/block/buzz/pull/1157)) ([`a96d173e`](https://github.com/block/buzz/commit/a96d173e996da82a1e59fbdd60081ead01a0e869))
|
||||
- docs: add product screenshots to README ([#1168](https://github.com/block/buzz/pull/1168)) ([`ff976e69`](https://github.com/block/buzz/commit/ff976e690e6488b0ea90336d5d724ccb097548ac))
|
||||
- Use accent color for video timecodes ([#1167](https://github.com/block/buzz/pull/1167)) ([`c243a013`](https://github.com/block/buzz/commit/c243a01362e03e5436e22618fe60f89e71fed1b2))
|
||||
- Add video playback speed control ([#1164](https://github.com/block/buzz/pull/1164)) ([`f218b815`](https://github.com/block/buzz/commit/f218b8158ef426b26013f0508668f3fee000b368))
|
||||
- Update README.md ([`fee4b1f5`](https://github.com/block/buzz/commit/fee4b1f556b97e06dfb5dda34859ddf6250e13ea))
|
||||
|
||||
|
||||
## v0.3.27
|
||||
|
||||
- chore: remove block team id from Xcode ([#1156](https://github.com/block/buzz/pull/1156)) ([`67fcf41b`](https://github.com/block/buzz/commit/67fcf41b53e081d1bed950257a3372683905a81c))
|
||||
- fix(desktop): keep channel chrome clear of window controls ([#1159](https://github.com/block/buzz/pull/1159)) ([`fa10871d`](https://github.com/block/buzz/commit/fa10871df61f4426441e887839772e49f4e03196))
|
||||
- desktop: polish sidebar search dialog ([#1155](https://github.com/block/buzz/pull/1155)) ([`5f79830e`](https://github.com/block/buzz/commit/5f79830e191102b4884487474ea70e1e522788a2))
|
||||
- desktop: fix scrollback paging and visible history depth ([#1153](https://github.com/block/buzz/pull/1153)) ([`6cca7599`](https://github.com/block/buzz/commit/6cca75993be9b61c9a50159130492c183fcd2304))
|
||||
- Fix ACP activity tool title detection ([#1149](https://github.com/block/buzz/pull/1149)) ([`4c7df9b0`](https://github.com/block/buzz/commit/4c7df9b0b3ea6884d0d9f59d7d1b244542885495))
|
||||
- fix(desktop): clear channel unread badges from thread reads ([#1148](https://github.com/block/buzz/pull/1148)) ([`7c3e411f`](https://github.com/block/buzz/commit/7c3e411fa8df14ef706bfd657899de6ec1f976ff))
|
||||
- desktop: move global search into sidebar header and remove vestigial top band ([#1150](https://github.com/block/buzz/pull/1150)) ([`c488d845`](https://github.com/block/buzz/commit/c488d8452c50f882a28b3f09224f3c6a30fb1b47))
|
||||
- Update README.md ([`7c67dfa6`](https://github.com/block/buzz/commit/7c67dfa66d38c1f25d7fab185ea1b2584bb82898))
|
||||
- Hide channel intro actions until history start ([#1151](https://github.com/block/buzz/pull/1151)) ([`7aa6daa4`](https://github.com/block/buzz/commit/7aa6daa4967c88dcda45560f6d4c5d2a0a2a63da))
|
||||
- fix(desktop): remount timeline scroll node per channel ([#1147](https://github.com/block/buzz/pull/1147)) ([`0c5dbf34`](https://github.com/block/buzz/commit/0c5dbf3452802a0370c1bdb445efa602c98c4148))
|
||||
- Render custom emoji in the inbox; DRY up emoji derivation ([#1146](https://github.com/block/buzz/pull/1146)) ([`39626197`](https://github.com/block/buzz/commit/39626197c0c80ae29dbb9814b6f3b6a603b174d0))
|
||||
- desktop: open thread pane optimistically ([#1143](https://github.com/block/buzz/pull/1143)) ([`663b554c`](https://github.com/block/buzz/commit/663b554c7c42371aac66fa2d6cb9524280f88600))
|
||||
- fix(desktop): keep channel header search/add icons always visible ([#1144](https://github.com/block/buzz/pull/1144)) ([`9c685282`](https://github.com/block/buzz/commit/9c6852824eecceecb5d504ce61a4906dd6a2aab9))
|
||||
- fix(desktop): show skeleton during channel switch ([#1141](https://github.com/block/buzz/pull/1141)) ([`d1c3574b`](https://github.com/block/buzz/commit/d1c3574ba73b2febb8ee8306b58fddffce6001bf))
|
||||
- Avoid duplicate thread event fetches ([#1129](https://github.com/block/buzz/pull/1129)) ([`959f2598`](https://github.com/block/buzz/commit/959f25982bbd135182c7c9f0d9c810a0324f1b2e))
|
||||
- desktop: restore channel browse button ([#1140](https://github.com/block/buzz/pull/1140)) ([`cc8958f5`](https://github.com/block/buzz/commit/cc8958f5cc0421c1cabe7cee882da898fc09e1f0))
|
||||
- desktop: make date dividers pill shaped ([#1139](https://github.com/block/buzz/pull/1139)) ([`bee41597`](https://github.com/block/buzz/commit/bee41597157eec63ec631caf3b1b6fb6e114e268))
|
||||
- fix(desktop): align sidebar unread pills ([#1138](https://github.com/block/buzz/pull/1138)) ([`59754218`](https://github.com/block/buzz/commit/5975421889f2cdf31f353deb0b401ddfc78b86c4))
|
||||
- Fix display names flashing to pubkeys when loading older messages ([#1137](https://github.com/block/buzz/pull/1137)) ([`c448f1c7`](https://github.com/block/buzz/commit/c448f1c72966b045f7d9b84560b3c1d04ca8b72c))
|
||||
- desktop: keep sidebar unread pill below top chrome strip ([#1136](https://github.com/block/buzz/pull/1136)) ([`aeafb0b0`](https://github.com/block/buzz/commit/aeafb0b0b2623789a47be78cbd35782866fb00b3))
|
||||
- fix(desktop): restore sticky date divider handoff ([#1135](https://github.com/block/buzz/pull/1135)) ([`3e9d7ff4`](https://github.com/block/buzz/commit/3e9d7ff4d6a152c944cf45ecfa70b641eac0f393))
|
||||
- desktop: drop redundant post-subscribe history refetch ([#1130](https://github.com/block/buzz/pull/1130)) ([`b2bf471e`](https://github.com/block/buzz/commit/b2bf471ea581e768647f3f3ad055bffbf7803f08))
|
||||
|
||||
|
||||
## v0.3.26
|
||||
|
||||
- ci: cache Flutter pub packages in the Mobile job ([#1128](https://github.com/block/buzz/pull/1128)) ([`ceb5eff53`](https://github.com/block/buzz/commit/ceb5eff53717a18afb9ac6cd3f3a93f01394b5bc))
|
||||
- Delete RESEARCH directory ([`a1d5570d3`](https://github.com/block/buzz/commit/a1d5570d3136c2c52823f512a5c59429f8995ca0))
|
||||
- feat(cli): support ephemeral channels via --ttl on create/update ([#1126](https://github.com/block/buzz/pull/1126)) ([`4f671a255`](https://github.com/block/buzz/commit/4f671a255cb526520151596b01dcf4a50ab2242d))
|
||||
- fix(justfile): suppress grep exit code in release changelog formatter ([`d40c86626`](https://github.com/block/buzz/commit/d40c86626a3c2d10eaa647f0e4664b32ef9060a5))
|
||||
- fix(desktop): single-owner anchored scroll for dynamic loading ([#1115](https://github.com/block/buzz/pull/1115)) ([`047db4214`](https://github.com/block/buzz/commit/047db4214e6ce79a615b8842e83f9f03c8e10f55))
|
||||
- fix(buzz-dev-mcp): resolve non-WSL bash so the MCP shell works on Windows ([#1119](https://github.com/block/buzz/pull/1119)) ([`4d2eb52a6`](https://github.com/block/buzz/commit/4d2eb52a6c89fe71f37c2c5abf3fed9e95d45977))
|
||||
- ci: key main-push concurrency group on SHA to stop run eviction ([#1124](https://github.com/block/buzz/pull/1124)) ([`e99105edc`](https://github.com/block/buzz/commit/e99105edc47bf36facbadbc94aeea99c7f15dd38))
|
||||
- fix(desktop): seed up agent JSON files in sync_shared_agent_data ([#1121](https://github.com/block/buzz/pull/1121)) ([`065625bb4`](https://github.com/block/buzz/commit/065625bb4f8dc49e1d3d56698c637383f9f82eef))
|
||||
- fix(acp): emit per-section prompt blocks so observer counts every section ([#1122](https://github.com/block/buzz/pull/1122)) ([`a1cf1db67`](https://github.com/block/buzz/commit/a1cf1db67faf9728fa1301ece5ef838ff2b2d906))
|
||||
- refactor(justfile): rename, relay auto-start, bootstrap, DRY cleanup ([#1117](https://github.com/block/buzz/pull/1117)) ([`1dc4fb5da`](https://github.com/block/buzz/commit/1dc4fb5daf048e5e44af82450b067175c75814c2))
|
||||
- fix(desktop): keep active-turn badges through transient relay drops ([#1120](https://github.com/block/buzz/pull/1120)) ([`cf122fcf1`](https://github.com/block/buzz/commit/cf122fcf1406378e5de2e652bd3f66ce5516f56b))
|
||||
- Polish desktop sidebar navigation ([#1107](https://github.com/block/buzz/pull/1107)) ([`7072281f6`](https://github.com/block/buzz/commit/7072281f63de5005b9046af58ef3fb38b151ee09))
|
||||
- fix(desktop): collapse thread-unread badge on thread-open ([#1118](https://github.com/block/buzz/pull/1118)) ([`0176d5f31`](https://github.com/block/buzz/commit/0176d5f31ec327641444fc89edb8ee1f04638578))
|
||||
- feat(desktop): unify unread pills into one shared UnreadPill component ([#1111](https://github.com/block/buzz/pull/1111)) ([`826945e1b`](https://github.com/block/buzz/commit/826945e1bbee2b93a2bc5ec13c2d1c3241bb7754))
|
||||
- fix(cli): use relay workflow id on create ([#872](https://github.com/block/buzz/pull/872)) ([`762ca43d2`](https://github.com/block/buzz/commit/762ca43d26be8f5376f96a1376ed7eb6f213e411))
|
||||
- Fold agent core memory into the session system prompt ([#1112](https://github.com/block/buzz/pull/1112)) ([`633544788`](https://github.com/block/buzz/commit/6335447887f2d8cc8ea7fa2468f37a287a08e224))
|
||||
- feat(cli): add patches and issues commands for NIP-34 git collaboration ([#1073](https://github.com/block/buzz/pull/1073)) ([`cd8292fe9`](https://github.com/block/buzz/commit/cd8292fe9e7941b3b25510c604491041a723cb7a))
|
||||
- fix(desktop): stop random timeline message loss + page reconnect replay ([#1105](https://github.com/block/buzz/pull/1105)) ([`0181603eb`](https://github.com/block/buzz/commit/0181603eba2a1a632467105d00fd41ddb96c526a))
|
||||
- Update README.md ([`c338d8840`](https://github.com/block/buzz/commit/c338d8840cb21ad27475b1d4b29f6fec539ca663))
|
||||
- fix(desktop): keep thread replies from scrolling channel ([#1109](https://github.com/block/buzz/pull/1109)) ([`d24eac61a`](https://github.com/block/buzz/commit/d24eac61a86f0da63e12e2758863b8364b67320d))
|
||||
- fix(buzz-acp): accept siblings under allowlist author gate ([#1108](https://github.com/block/buzz/pull/1108)) ([`dd9ce0902`](https://github.com/block/buzz/commit/dd9ce090209c282921c7c0e821795b12ac69796f))
|
||||
- feat(deploy): add production Helm chart for Buzz ([#990](https://github.com/block/buzz/pull/990)) ([`629fb57bf`](https://github.com/block/buzz/commit/629fb57bf0802f1b57eaa3068fc6eaad85b9d38f))
|
||||
- fix(desktop): keep MembersSidebar input usable while an add is in flight ([#1106](https://github.com/block/buzz/pull/1106)) ([`aadbe6735`](https://github.com/block/buzz/commit/aadbe67354f407a31c913de87b3e3d698fc3dddf))
|
||||
|
||||
|
||||
## v0.3.25
|
||||
|
||||
- fix(desktop): stop dimming deferred message lists ([#1104](https://github.com/block/buzz/pull/1104)) ([`718b596e5`](https://github.com/block/buzz/commit/718b596e5b696dbe439993c40a16ba39dd862828))
|
||||
- Smooth channel loading: single-surface timeline state machine ([#1099](https://github.com/block/buzz/pull/1099)) ([`d47572209`](https://github.com/block/buzz/commit/d47572209dd26ee11cb7d77c85d420fb11947ce8))
|
||||
- feat: surface base + persona system prompts in observer feed ([#1103](https://github.com/block/buzz/pull/1103)) ([`5004758fb`](https://github.com/block/buzz/commit/5004758fbfda8ea9c014174b43464b9835952090))
|
||||
- ci: move reminder e2e to a dedicated backend-integration job ([#1098](https://github.com/block/buzz/pull/1098)) ([`466bb993c`](https://github.com/block/buzz/commit/466bb993ca5a5bbf99db47ec31356b1a36e9aa86))
|
||||
- fix: give agent-observer sub a replay-capable limit ([#1100](https://github.com/block/buzz/pull/1100)) ([`959fc6e9d`](https://github.com/block/buzz/commit/959fc6e9d0a68ff4de1e3b4dc3d731fb8f0b3b03))
|
||||
- fix: make managed-agent spawn and teardown portable to Windows ([#1097](https://github.com/block/buzz/pull/1097)) ([`420131182`](https://github.com/block/buzz/commit/42013118215bbeb87a4fed30c5744e99bb0d8ae9))
|
||||
- fix(desktop): constrain message timeline width with min-w-0 ([#1092](https://github.com/block/buzz/pull/1092)) ([`7de5517f1`](https://github.com/block/buzz/commit/7de5517f1d3d48e929e49fb8be20e04995428b50))
|
||||
- feat(desktop): reminders notifications, snooze, overlay, and inbox view mode ([#1093](https://github.com/block/buzz/pull/1093)) ([`22b47db8e`](https://github.com/block/buzz/commit/22b47db8ecb80fb6deccad8323b38f7cbb1f5cf7))
|
||||
- feat(prompt): add memory hygiene and hoist universal engineering discipline to base prompt ([#1085](https://github.com/block/buzz/pull/1085)) ([`010e8022b`](https://github.com/block/buzz/commit/010e8022b0b07b135db652d61afd7250c27e0564))
|
||||
- fix(desktop): correct thread-unread badge flicker, stale clear, phantom count, mention gate, and nested count ([#1080](https://github.com/block/buzz/pull/1080)) ([`c8e1120fc`](https://github.com/block/buzz/commit/c8e1120fc2e78424526f49248b5d531a567a97b8))
|
||||
- Fix mention chip alignment ([#1094](https://github.com/block/buzz/pull/1094)) ([`4f93d52e1`](https://github.com/block/buzz/commit/4f93d52e156a5683f114897d43f25f75704a292e))
|
||||
- perf(desktop): virtualize unbounded lists and warm the emoji index ([#1089](https://github.com/block/buzz/pull/1089)) ([`a4fbebb39`](https://github.com/block/buzz/commit/a4fbebb397c0af4f19b3fc7af531ff067d8dead0))
|
||||
- Adjust unread pill spacing ([#1091](https://github.com/block/buzz/pull/1091)) ([`3bf2ac004`](https://github.com/block/buzz/commit/3bf2ac00476440cc454c4d5b2ada87073c808500))
|
||||
- Improve image lightbox controls ([#1084](https://github.com/block/buzz/pull/1084)) ([`bb9b6674e`](https://github.com/block/buzz/commit/bb9b6674e36a38d3a6395153930d2fb8ed502f45))
|
||||
- Use Inter for app typography ([#899](https://github.com/block/buzz/pull/899)) ([`7b0500c93`](https://github.com/block/buzz/commit/7b0500c93e8923c0826a28af4fee3c88ed688280))
|
||||
- fix(desktop): paint thread replies on open without scroll nudge ([#1090](https://github.com/block/buzz/pull/1090)) ([`422a90f5e`](https://github.com/block/buzz/commit/422a90f5e180d066128c51e45991278d39a707de))
|
||||
- Polish sidebar update and relay cards ([#1009](https://github.com/block/buzz/pull/1009)) ([`d4cd919b8`](https://github.com/block/buzz/commit/d4cd919b8506469f0bfd68100f13cdf16d3e76bc))
|
||||
- Normalize desktop icon sizing ([#1088](https://github.com/block/buzz/pull/1088)) ([`1a37ee395`](https://github.com/block/buzz/commit/1a37ee395119e5e54cb2798346bad4b5c9a5b29c))
|
||||
- fix(desktop): normalize loopback host for HTTP writes and stop Reminders nav flicker ([#1086](https://github.com/block/buzz/pull/1086)) ([`771086fd6`](https://github.com/block/buzz/commit/771086fd69243b3bdccf213783190ad119486de9))
|
||||
- feat(composer): link editing — inline popover, modal picker, buzz:// in-app nav ([#1045](https://github.com/block/buzz/pull/1045)) ([`770266d2a`](https://github.com/block/buzz/commit/770266d2a99925b92716b8991139f223d8ff5dda))
|
||||
- chore(Pulse): gate Follow button behind Pulse feature flag ([#1019](https://github.com/block/buzz/pull/1019)) ([`48994e01b`](https://github.com/block/buzz/commit/48994e01bca2345d56fd0cf72458caa52686e2ec))
|
||||
- fix(desktop): wrap long markdown autolinks ([#1081](https://github.com/block/buzz/pull/1081)) ([`b7e22f4b5`](https://github.com/block/buzz/commit/b7e22f4b5c13b995fe26587c2a52bbe0e775e0b9))
|
||||
- feat(desktop): add NIP-ER reminder UI — create, view, and manage encrypted reminders ([#963](https://github.com/block/buzz/pull/963)) ([`ff824a365`](https://github.com/block/buzz/commit/ff824a365e95651edc08b7721e81e091734e8664))
|
||||
- feat(relay): add NIP-ER push scheduler with cross-pod delivery ([#957](https://github.com/block/buzz/pull/957)) ([`26563968c`](https://github.com/block/buzz/commit/26563968cba232db7b16c0b696273819b5e2c571))
|
||||
- feat(relay): implement NIP-ER event reminder support (kind:30300) ([#934](https://github.com/block/buzz/pull/934)) ([`79fcfd82b`](https://github.com/block/buzz/commit/79fcfd82bc3a883e1400449f87779853dc0e575c))
|
||||
- feat(desktop): adding ui state into the history stack ([#967](https://github.com/block/buzz/pull/967)) ([`538f33341`](https://github.com/block/buzz/commit/538f33341fbdfa9794728dabd5ce995769dc151c))
|
||||
- fix(desktop): restore timeline zoom via rem tokens + chat-as-base type scale ([#1052](https://github.com/block/buzz/pull/1052)) ([`c22c54e7a`](https://github.com/block/buzz/commit/c22c54e7ae4318f9648dc9441a152732cb29d6d5))
|
||||
- fix(release): format changelog as linked markdown bullets ([#1075](https://github.com/block/buzz/pull/1075)) ([`d879fac4c`](https://github.com/block/buzz/commit/d879fac4c424550d5fa839861c53583cdf032af5))
|
||||
|
||||
|
||||
## v0.3.24
|
||||
|
||||
- feat(desktop): refine thread-unread badge to two-token form ([#1069](https://github.com/block/buzz/pull/1069)) ([`de24b90aa`](https://github.com/block/buzz/commit/de24b90aa1076822c4de184c3bb8b5f6ebc682a2))
|
||||
- fix(buzz): prevent reconnect storms from reaped ephemeral channels ([#1071](https://github.com/block/buzz/pull/1071)) ([`fd2553726`](https://github.com/block/buzz/commit/fd2553726c6a8277c472b44227d13546673117b2))
|
||||
- fix(buzz-acp): trim oversized observer frames to fit instead of dropping ([#1072](https://github.com/block/buzz/pull/1072)) ([`5a651632d`](https://github.com/block/buzz/commit/5a651632d873d29d9867fc2a1445547dc519e95d))
|
||||
- perf(ci): speed up PR CI wall clock and local dev builds ([#1028](https://github.com/block/buzz/pull/1028)) ([`07efae7b5`](https://github.com/block/buzz/commit/07efae7b5b8948ba12231436e7f2820b42d37496))
|
||||
- chore(deps): update react monorepo ([#1048](https://github.com/block/buzz/pull/1048)) ([`56a5ac279`](https://github.com/block/buzz/commit/56a5ac27900661aa1674b93f81867d2930ccff93))
|
||||
- Polish desktop visual details ([#1067](https://github.com/block/buzz/pull/1067)) ([`9f99e62d4`](https://github.com/block/buzz/commit/9f99e62d409fe7d5f9330de5641e9c6fcfc3b752))
|
||||
- ci: use running postgres for pgschema desired-state planning ([#1070](https://github.com/block/buzz/pull/1070)) ([`e3736f08b`](https://github.com/block/buzz/commit/e3736f08b77b81d3752c3aceb1e25022e5dc759b))
|
||||
- fix(desktop): anchor active-turn badge to skew-corrected agent start ([#1068](https://github.com/block/buzz/pull/1068)) ([`2d26db6d8`](https://github.com/block/buzz/commit/2d26db6d8a7381ff0dc9b50194fd28d42957de74))
|
||||
- feat(desktop): add configurable transport reconnect hook ([#1059](https://github.com/block/buzz/pull/1059)) ([`ba776b995`](https://github.com/block/buzz/commit/ba776b9959324e10ffacf40126875783f0310ac0))
|
||||
- Add automatic database migrations ([#988](https://github.com/block/buzz/pull/988)) ([`2300248d3`](https://github.com/block/buzz/commit/2300248d3b40359da7d92b6665041a70b5520047))
|
||||
- Add composer spoiler formatting ([#1055](https://github.com/block/buzz/pull/1055)) ([`f8715612a`](https://github.com/block/buzz/commit/f8715612afb03f1c7938432141c9904c20e9b9ee))
|
||||
- feat(desktop): in-channel and in-thread unread indicators ([#1008](https://github.com/block/buzz/pull/1008)) ([`2a2c1c800`](https://github.com/block/buzz/commit/2a2c1c8001a2eb6dbf35b3f13ec5468d1a7a1417))
|
||||
- perf(timeline): gate heavy message render behind useDeferredValue ([#1022](https://github.com/block/buzz/pull/1022)) ([`cbc754cff`](https://github.com/block/buzz/commit/cbc754cffb0e14c25b380de6c2d5f8d62c14a183))
|
||||
- Add animated profile avatars ([#1031](https://github.com/block/buzz/pull/1031)) ([`116486592`](https://github.com/block/buzz/commit/116486592d05f8ab079b2d26a8805d8fe7a055b5))
|
||||
- Polish direct message and members modals ([#1054](https://github.com/block/buzz/pull/1054)) ([`89ae31d20`](https://github.com/block/buzz/commit/89ae31d2034eab36445be9b43d9edf51cd34576f))
|
||||
- Polish huddles UI ([#1041](https://github.com/block/buzz/pull/1041)) ([`5234fc816`](https://github.com/block/buzz/commit/5234fc81675f46a0d616d336453c51f9b90f66cd))
|
||||
- Fix video review comments in threads ([#1056](https://github.com/block/buzz/pull/1056)) ([`424ea7025`](https://github.com/block/buzz/commit/424ea70254f7703ec31688aa7096f145ec4a59a8))
|
||||
- Polish message reaction tray ([#1002](https://github.com/block/buzz/pull/1002)) ([`81296d976`](https://github.com/block/buzz/commit/81296d9766af0f0ddecd9e14526cb9456952b489))
|
||||
- Refine app loading skeletons ([#1001](https://github.com/block/buzz/pull/1001)) ([`c30d7274c`](https://github.com/block/buzz/commit/c30d7274c2febee6b274ea0160b9465cc93899b8))
|
||||
- Polish channel modal forms ([#1000](https://github.com/block/buzz/pull/1000)) ([`ee34ca818`](https://github.com/block/buzz/commit/ee34ca818bc19a3f7f62f00debe4c7f2862b906d))
|
||||
- Normalize desktop icon sizing ([#999](https://github.com/block/buzz/pull/999)) ([`19656fff4`](https://github.com/block/buzz/commit/19656fff46176e44ca7b4e8025f2fb7b34ee3589))
|
||||
- Add shared skeleton loader primitives ([#998](https://github.com/block/buzz/pull/998)) ([`7a2e35521`](https://github.com/block/buzz/commit/7a2e35521ca103d1f2086fb195b7d91e588dc070))
|
||||
- chore(scripts): update post-screenshots repo name to block/buzz ([#1042](https://github.com/block/buzz/pull/1042)) ([`9ee5aeebd`](https://github.com/block/buzz/commit/9ee5aeebd912940fc09ed9707ff716b63bc28409))
|
||||
- docs: fix stale sprout repo references in RELEASING.md ([#1043](https://github.com/block/buzz/pull/1043)) ([`b2ad3074b`](https://github.com/block/buzz/commit/b2ad3074bacf8f29d179c80003228b4dd451efd0))
|
||||
|
||||
|
||||
## v0.3.23
|
||||
|
||||
- fix(release): publish manifest from successful platforms ([#1039](https://github.com/block/buzz/pull/1039)) ([`9b410325a`](https://github.com/block/buzz/commit/9b410325a754305405dbaf80b63546df9af403ea))
|
||||
|
||||
|
||||
## v0.3.22
|
||||
|
||||
- fix(release): publish rolling updater manifest for automated release tags
|
||||
|
||||
|
||||
## v0.3.21
|
||||
|
||||
- fix(release): use signed NSIS installer for updates ([#1036](https://github.com/block/buzz/pull/1036)) ([`4d19a5901`](https://github.com/block/buzz/commit/4d19a5901d45aab7c615440e725e6d17d799e0da))
|
||||
- handoff: pass full session history to summarizer ([#1033](https://github.com/block/buzz/pull/1033)) ([`fa1cade3b`](https://github.com/block/buzz/commit/fa1cade3ba76309bcbeac5e87e8932e2087622f2))
|
||||
- feat(emoji): latest-set-wins union for custom emoji across desktop, mobile, and CLI ([#989](https://github.com/block/buzz/pull/989)) ([`6e4c8680c`](https://github.com/block/buzz/commit/6e4c8680c8c2346c8ff4612e79343d55b435d0d6))
|
||||
- Fix relay NIP-11 software URL ([#1030](https://github.com/block/buzz/pull/1030)) ([`5f8ab33b3`](https://github.com/block/buzz/commit/5f8ab33b3b132decc35b80723fb8f197d37edda9))
|
||||
- fix(desktop): make Windows release compile cleanly ([#1029](https://github.com/block/buzz/pull/1029)) ([`5c2f46e62`](https://github.com/block/buzz/commit/5c2f46e627e8a9c257e183bf98289c31757fc6b3))
|
||||
- Add production Docker Compose bundle ([#985](https://github.com/block/buzz/pull/985)) ([`6caa359d7`](https://github.com/block/buzz/commit/6caa359d7052d494d1b2f55290d93d9e603e74dd))
|
||||
- feat(profile): show active turn badges on agent profile panel and popover ([#1026](https://github.com/block/buzz/pull/1026)) ([`a32681fd4`](https://github.com/block/buzz/commit/a32681fd48e58355052d6980bc8f44d79cf4cd66))
|
||||
|
||||
|
||||
## v0.3.20
|
||||
|
||||
- fix(release): resolve Windows sidecar path and Linux AppImage updater format ([#1024](https://github.com/block/buzz/pull/1024)) ([`c7dd4295b`](https://github.com/block/buzz/commit/c7dd4295b8feee4acd78bac86d1d92cf7e7463ad))
|
||||
|
||||
|
||||
## v0.3.19
|
||||
|
||||
- fix(release): ignore prerelease tags in changelog generation ([#1021](https://github.com/block/buzz/pull/1021)) ([`faf00724f`](https://github.com/block/buzz/commit/faf00724fb4d187ad92a378b4069bf4fd1b4033f))
|
||||
- fix: repair main build after cross-PR merge skew ([#1020](https://github.com/block/buzz/pull/1020)) ([`b8c0556e7`](https://github.com/block/buzz/commit/b8c0556e7cdda13c52ba1cf80e675e4fc40b6696))
|
||||
- feat(agents): show per-turn duration and prune dead turns within ~25s of host crash ([#1017](https://github.com/block/buzz/pull/1017)) ([`87e45c65b`](https://github.com/block/buzz/commit/87e45c65bf9a0c841df2262f712a30c554888cac))
|
||||
- fix(release): replace hermit with native tool setup on Windows job ([#1018](https://github.com/block/buzz/pull/1018)) ([`2fef8d664`](https://github.com/block/buzz/commit/2fef8d6643ad15268254d89ec84199c735142404))
|
||||
- feat(acp): surface error-class outcomes to the activity feed only, never the channel ([#1010](https://github.com/block/buzz/pull/1010)) ([`6db90514b`](https://github.com/block/buzz/commit/6db90514b24d9c70073d52c9c8e8df332ccb82d5))
|
||||
- fix(desktop): migrate Sprout workspace storage ([#1016](https://github.com/block/buzz/pull/1016)) ([`563f68434`](https://github.com/block/buzz/commit/563f684342cdf7b59642b124f9349b0a3ae3d284))
|
||||
- feat(auth): force token refresh on rejected token (401/403), never the browser ([#1015](https://github.com/block/buzz/pull/1015)) ([`5a8cc79c6`](https://github.com/block/buzz/commit/5a8cc79c6a1d229b0ef7d33e1b98168d6d7841c0))
|
||||
- fix(release): mark prerelease versions so they do not become latest ([#1013](https://github.com/block/buzz/pull/1013)) ([`59a7e5da8`](https://github.com/block/buzz/commit/59a7e5da8af296a51ff43fe8ff4b09e4bb9c48fa))
|
||||
- feat(acp): implement systemPrompt with protocol version gating ([#981](https://github.com/block/buzz/pull/981)) ([`f08588245`](https://github.com/block/buzz/commit/f0858824548e97a73451ae6178a9ed14299cee42))
|
||||
- fix(release): update repository name check from block/sprout to block/buzz ([#1012](https://github.com/block/buzz/pull/1012)) ([`d07c8216c`](https://github.com/block/buzz/commit/d07c8216cc1550a1a101f4e478d5deb3d60fa633))
|
||||
- feat(release): all-OS desktop builds + universal auto-update manifest ([#1011](https://github.com/block/buzz/pull/1011)) ([`de641fce5`](https://github.com/block/buzz/commit/de641fce5247994958635244c29e2fd8fa51ccf0))
|
||||
- Add relay disconnect UX: friendly errors, reconnect, cached identity ([#1004](https://github.com/block/buzz/pull/1004)) ([`8c9211ffc`](https://github.com/block/buzz/commit/8c9211ffc675000bdf55c4643528cf5d32a2d27a))
|
||||
- feat(agents): add active turn indicators to Agents Menu ([#1005](https://github.com/block/buzz/pull/1005)) ([`7983bf675`](https://github.com/block/buzz/commit/7983bf6751f12562b2703dce73697e5cfb7f44da))
|
||||
- ci: add fork guards to docker, release, and auto-tag workflows ([#1007](https://github.com/block/buzz/pull/1007)) ([`39d9aa826`](https://github.com/block/buzz/commit/39d9aa8260e0c8eb6fdc6277c8138b63688dad0c))
|
||||
- docs(nip-rs): add optional thread read context scheme ([#1006](https://github.com/block/buzz/pull/1006)) ([`43d1ce353`](https://github.com/block/buzz/commit/43d1ce3532b769b23c20e0cb5228e007d574f0d3))
|
||||
- fix(huddle): Pocket TTS quality overhaul — reference parity + cross-message pipelining ([#997](https://github.com/block/buzz/pull/997)) ([`12433077a`](https://github.com/block/buzz/commit/12433077add076dd89f5412a55ec3048b6b1b64d))
|
||||
- Add manual ACP session rotation command ([#932](https://github.com/block/buzz/pull/932)) ([`00dc4915d`](https://github.com/block/buzz/commit/00dc4915d4b874c312a82fa707ee4d8f2437f61f))
|
||||
- fix(desktop): heal stale persona_team_dir paths in release builds ([#1003](https://github.com/block/buzz/pull/1003)) ([`df8896f13`](https://github.com/block/buzz/commit/df8896f13ec5a937a443b968439016823f34e6b6))
|
||||
- ci(docker): publish public ghcr.io/block/buzz image (native multi-arch) ([#986](https://github.com/block/buzz/pull/986)) ([`1fa63bada`](https://github.com/block/buzz/commit/1fa63badad283cff753ab403105344060fa74efa))
|
||||
- fix(buzz-agent): cap tool-result text at 50 KiB with middle elision ([#952](https://github.com/block/buzz/pull/952)) ([`84f499cb6`](https://github.com/block/buzz/commit/84f499cb6ed65f8889ae7f0401451c02696b164e))
|
||||
- feat(huddle): sentence-at-a-time voice-mode guidelines for lower TTS latency ([#996](https://github.com/block/buzz/pull/996)) ([`2846a96ed`](https://github.com/block/buzz/commit/2846a96ed2ed64077da3b7ad866e40c1cf26e952))
|
||||
- Shard desktop Playwright CI jobs ([#992](https://github.com/block/buzz/pull/992)) ([`a1c28f487`](https://github.com/block/buzz/commit/a1c28f487d2af01d620619d940cf377d21c1a81a))
|
||||
|
||||
|
||||
## v0.3.18
|
||||
|
||||
- Video Player Improvements ([#993](https://github.com/block/buzz/pull/993)) ([`05fc69b84`](https://github.com/block/buzz/commit/05fc69b84082d7e7e9360cab7ae9c9c0524511f9))
|
||||
- Improve first-run welcome setup ([#970](https://github.com/block/buzz/pull/970)) ([`d9ce0943e`](https://github.com/block/buzz/commit/d9ce0943edb286404a3c99b501d53e7b35095955))
|
||||
- fix(release): use legacy updater key secret ([#991](https://github.com/block/buzz/pull/991)) ([`50986406f`](https://github.com/block/buzz/commit/50986406ffa1f4e3dcff3e5373a3e0838d11fb2b))
|
||||
- Replace built-in personas with Fizz ([#987](https://github.com/block/buzz/pull/987)) ([`ea5a0a9b4`](https://github.com/block/buzz/commit/ea5a0a9b40592d6192fb0ddbb99bf257009de5a8))
|
||||
- docs(buzz-acp): rewrite Communication Patterns for mention accuracy and threading clarity ([#982](https://github.com/block/buzz/pull/982)) ([`654176541`](https://github.com/block/buzz/commit/6541765416b9da25dcd8c5c9da692b33d12782c7))
|
||||
- chore(justfile): build git-credential-nostr in dev and staging recipes ([#980](https://github.com/block/buzz/pull/980)) ([`a101fd6ad`](https://github.com/block/buzz/commit/a101fd6ad38d4c2d8c668c2d5d79d2a35ab71176))
|
||||
- Fix Buzz command migration for saved agents ([#979](https://github.com/block/buzz/pull/979)) ([`824c55114`](https://github.com/block/buzz/commit/824c55114ef388990f22df901caf9e37b1102b04))
|
||||
- fix(desktop): resolve effective model and prompt from persona in display path ([#972](https://github.com/block/buzz/pull/972)) ([`63738139b`](https://github.com/block/buzz/commit/63738139bac3ce860fd6a0a50dc5883a374d6245))
|
||||
- docs: clean up remaining Buzz references ([#977](https://github.com/block/buzz/pull/977)) ([`1bb8b8d54`](https://github.com/block/buzz/commit/1bb8b8d547a020622948225b32a8f9bbf3b0de2e))
|
||||
|
||||
|
||||
## v0.3.17
|
||||
|
||||
- docs: finish Buzz rename cleanup ([#974](https://github.com/block/buzz/pull/974)) ([`79bcee55c`](https://github.com/block/buzz/commit/79bcee55cb717b117f267145a6b82d477f5aa428))
|
||||
- fix(desktop): let channel members bypass mention agent gate ([#965](https://github.com/block/buzz/pull/965)) ([`6f3733d43`](https://github.com/block/buzz/commit/6f3733d43b4296b228f06874d10d22a7bb9a3c73))
|
||||
- Rename desktop app to Buzz ([#960](https://github.com/block/buzz/pull/960)) ([`8f580f308`](https://github.com/block/buzz/commit/8f580f308cc3d92b07a65ca08cb117e8f7656a7d))
|
||||
- feat(desktop): open profile panel from MembersSidebar rows ([#962](https://github.com/block/buzz/pull/962)) ([`dcb2639b3`](https://github.com/block/buzz/commit/dcb2639b355c988ca6c5529640cfd55c957edd05))
|
||||
- feat(desktop): per-event notification sounds and alert controls ([#968](https://github.com/block/buzz/pull/968)) ([`4e4dc723e`](https://github.com/block/buzz/commit/4e4dc723e4cc1969efe9a8cc8d6e0e95a8e2d695))
|
||||
- fix(desktop): make header chrome zoom-correct and tidy split-pane ([#941](https://github.com/block/buzz/pull/941)) ([`1ca16c898`](https://github.com/block/buzz/commit/1ca16c898c7ccd5c9a4f78768e697b411367c975))
|
||||
- fix(desktop): rename SPROUT_ env vars to BUZZ_ for child agent processes ([#971](https://github.com/block/buzz/pull/971)) ([`8c8312932`](https://github.com/block/buzz/commit/8c8312932af9dced17fe1225d899885ec603b08b))
|
||||
- fix(justfile): complete buzz rename in dev and staging recipes ([#966](https://github.com/block/buzz/pull/966)) ([`31b0665cf`](https://github.com/block/buzz/commit/31b0665cff7cb4735397696bd7ce03a17404d54a))
|
||||
- refactor: rename sprout backend to buzz ([#958](https://github.com/block/buzz/pull/958)) ([`d99ad131f`](https://github.com/block/buzz/commit/d99ad131f176040549b041e297b813ef9705663e))
|
||||
- fix(desktop): reap orphaned agent processes across instances ([#954](https://github.com/block/buzz/pull/954)) ([`53e3f0948`](https://github.com/block/buzz/commit/53e3f09485838fbff242c883ff412dd39d03ec27))
|
||||
- Rename web app to Buzz ([#959](https://github.com/block/buzz/pull/959)) ([`c5a54dcc3`](https://github.com/block/buzz/commit/c5a54dcc3909a319e1d3604de4415142a3903619))
|
||||
- fix(desktop): allow restarting saved relay-mesh agents from the UI ([#956](https://github.com/block/buzz/pull/956)) ([`510009c11`](https://github.com/block/buzz/commit/510009c11db58176c4af907414ecc994e36c6c0b))
|
||||
- feat(acp): agent timeout resilience — idle margin, tool-call reset, death notices, keepalive ([#935](https://github.com/block/buzz/pull/935)) ([`60c8c5036`](https://github.com/block/buzz/commit/60c8c5036a20d10b8c32c95dd40bbb4d6098b59e))
|
||||
- Rename mobile app to Buzz ([#955](https://github.com/block/buzz/pull/955)) ([`c63e018b0`](https://github.com/block/buzz/commit/c63e018b05b9e387b413f00417c2df6c5979fff5))
|
||||
- fix(desktop): repair team-persona mismatch and deduplicate legacy imports ([#949](https://github.com/block/buzz/pull/949)) ([`929cc8861`](https://github.com/block/buzz/commit/929cc8861ee5629737fd9f7dafe4501ee071fa1b))
|
||||
- fix(desktop): populate last_message_at in channel browser ([#951](https://github.com/block/buzz/pull/951)) ([`b792aa470`](https://github.com/block/buzz/commit/b792aa4704a2aeb669008c9c48736fc3acef35d9))
|
||||
- Kit/circular avatars ([#927](https://github.com/block/buzz/pull/927)) ([`e5f0c3264`](https://github.com/block/buzz/commit/e5f0c32648be83908d3f4e06c8f746581b365527))
|
||||
- fix(relay): accept mesh signaling kinds (24620/24621) via POST /events ([#946](https://github.com/block/buzz/pull/946)) ([`dbe973dac`](https://github.com/block/buzz/commit/dbe973dacd0165ad40689f28601660bb307c67ea))
|
||||
- feat(sprout-dev-mcp): add read_file tool and replace_all to str_replace ([#928](https://github.com/block/buzz/pull/928)) ([`f49cdcdd3`](https://github.com/block/buzz/commit/f49cdcdd300dca846a7cd7023a29e5bf54fc96a1))
|
||||
|
||||
|
||||
## v0.3.16
|
||||
|
||||
- fix(desktop): land live presence updates for not-yet-cached pubkeys ([#947](https://github.com/block/buzz/pull/947)) ([`34c8bdab1`](https://github.com/block/buzz/commit/34c8bdab1a8bde4fa6d24afcb081a60131e10ff3))
|
||||
- fix(release): make `just release` idempotent for re-runs ([#948](https://github.com/block/buzz/pull/948)) ([`5c0af0bc9`](https://github.com/block/buzz/commit/5c0af0bc93d713beb1f77f6c9bc3dad65c0ff15b))
|
||||
- Improve mentions for agents + people ([#942](https://github.com/block/buzz/pull/942)) ([`e9cd1c392`](https://github.com/block/buzz/commit/e9cd1c39264233317c1639d7389c1a843db7ef93))
|
||||
- feat: agent memory viewer (read-only) in profile panel ([#917](https://github.com/block/buzz/pull/917)) ([`384eb6cba`](https://github.com/block/buzz/commit/384eb6cba4d589627f6ddf92d288c00dd988361f))
|
||||
- Fix channel visibility controls ([#940](https://github.com/block/buzz/pull/940)) ([`2dc466fe0`](https://github.com/block/buzz/commit/2dc466fe0b45ee85e772af8ec5e47eeeb2051ce4))
|
||||
- fix(delete): make agent-deleted messages disappear from desktop UI immediately ([#918](https://github.com/block/buzz/pull/918)) ([`3e56331e9`](https://github.com/block/buzz/commit/3e56331e9c2866d736d3f92fec03ef065d19007e))
|
||||
- Fix emoji message rendering ([#938](https://github.com/block/buzz/pull/938)) ([`e08937cdd`](https://github.com/block/buzz/commit/e08937cddeff5702d8dfa3d7bfa187d2fbfcf305))
|
||||
- refactor(desktop): consolidate packs into teams ([#852](https://github.com/block/buzz/pull/852)) ([`ba2fdbf69`](https://github.com/block/buzz/commit/ba2fdbf6978c8904b26e6f62a2d88782740d890c))
|
||||
- Update README.md wording ([`384a34ec3`](https://github.com/block/buzz/commit/384a34ec31df67b10024e773c349fbd17030c51a))
|
||||
- Fix post-compact handoff context for OpenAI providers ([#931](https://github.com/block/buzz/pull/931)) ([`fe14daa5d`](https://github.com/block/buzz/commit/fe14daa5d6a6dc681d3838b4f61a65d9b694be19))
|
||||
|
||||
|
||||
## v0.3.15
|
||||
|
||||
- fix: persona is source of truth at spawn + thread-depth conventions ([#930](https://github.com/block/buzz/pull/930)) ([`877048d68`](https://github.com/block/buzz/commit/877048d68fb064947c8845d0051237064937660b))
|
||||
- fix: skip avatar reconciliation for legacy agent records ([#933](https://github.com/block/buzz/pull/933)) ([`73cd8d082`](https://github.com/block/buzz/commit/73cd8d082d474a5cd16bdd4b404010027ed3ad17))
|
||||
- feat(desktop): add nest commit identity guidance with human sign-off ([#929](https://github.com/block/buzz/pull/929)) ([`165b9f7a5`](https://github.com/block/buzz/commit/165b9f7a5f5ba3d5a48ff19d04c25b455058e537))
|
||||
- feat: provider/model selection for personas and runtime-aware env injection ([#794](https://github.com/block/buzz/pull/794)) ([`9a98e60fc`](https://github.com/block/buzz/commit/9a98e60fc29edacfb27134c19676d114c2ce20a2))
|
||||
- fix: reconcile agent profile on startup when relay publish was missed ([#921](https://github.com/block/buzz/pull/921)) ([`762a45969`](https://github.com/block/buzz/commit/762a45969a6bec0e6fe3efe6b86c2aa1f47d7eeb))
|
||||
- Revamp first-run onboarding ([#924](https://github.com/block/buzz/pull/924)) ([`5d927aba0`](https://github.com/block/buzz/commit/5d927aba0c45a4a139b559be89bb1bb1595e7fdb))
|
||||
- Update setup loading screen ([#926](https://github.com/block/buzz/pull/926)) ([`f36265019`](https://github.com/block/buzz/commit/f3626501952139974e641fecae3dc17fcfade187))
|
||||
- fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV) ([#857](https://github.com/block/buzz/pull/857)) ([`c38301bca`](https://github.com/block/buzz/commit/c38301bca5a3d8015f1e7340b4e732ef8c0e3744))
|
||||
- Maximize desktop window on launch ([#925](https://github.com/block/buzz/pull/925)) ([`4dfae61f2`](https://github.com/block/buzz/commit/4dfae61f24271fa2e0616e3d896b5f72f3a3ca0f))
|
||||
- feat: preview features (experiments settings UI) ([#888](https://github.com/block/buzz/pull/888)) ([`ae430d4dd`](https://github.com/block/buzz/commit/ae430d4dd954fc8cf9e96ea6ac6940714cde9181))
|
||||
- fix(updater): send no-cache header on update check to avoid stale manifest ([#922](https://github.com/block/buzz/pull/922)) ([`a357e220d`](https://github.com/block/buzz/commit/a357e220d82230277a123d809e3130dc359f0fbb))
|
||||
- fix(desktop): refresh channel state after unarchive ([#923](https://github.com/block/buzz/pull/923)) ([`56230c144`](https://github.com/block/buzz/commit/56230c1442f9881278910f6ecbc5bb66178e9bd6))
|
||||
- Add channel visibility & ephemeral TTL controls to manage sidebar ([#911](https://github.com/block/buzz/pull/911)) ([`7dd5b3453`](https://github.com/block/buzz/commit/7dd5b34536b82cb072b92a7e8000f543b4170d3a))
|
||||
- ci(release): add Intel macOS (x86_64) DMG as a release target ([#748](https://github.com/block/buzz/pull/748)) ([`4b78fe3be`](https://github.com/block/buzz/commit/4b78fe3bea22c44568bc656db74e577c748d7627))
|
||||
- mesh: Rust-owned coordinator — fix saved-agent reconnect flakiness + DRY the start path ([#879](https://github.com/block/buzz/pull/879)) ([`421593062`](https://github.com/block/buzz/commit/421593062f25ab19e9ce9fbbaa204d843231657f))
|
||||
|
||||
|
||||
## v0.3.14
|
||||
|
||||
- fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction ([#905](https://github.com/block/buzz/pull/905)) ([`bfafdd46b`](https://github.com/block/buzz/commit/bfafdd46b29a04421efdd95ee0a157e2da86ee54))
|
||||
- fix(desktop): re-enable mcp_command reconciliation and harden spawn site ([#909](https://github.com/block/buzz/pull/909)) ([`15f610dcd`](https://github.com/block/buzz/commit/15f610dcd5c1d1308beed167c316721b2deefc2d))
|
||||
- Fix desktop DM and sidebar UI polish ([#908](https://github.com/block/buzz/pull/908)) ([`da80c7340`](https://github.com/block/buzz/commit/da80c7340f300cd349cfee15b8f61c9a2f576b92))
|
||||
- Animate reaction counts ([#904](https://github.com/block/buzz/pull/904)) ([`dd08f988d`](https://github.com/block/buzz/commit/dd08f988dec85f42f2202bfad0589278a3d22a34))
|
||||
- Mobile custom emoji + settings redesign ([#906](https://github.com/block/buzz/pull/906)) ([`10b6674bd`](https://github.com/block/buzz/commit/10b6674bd7907e6bf3d81cc2a3f128d896d233f2))
|
||||
- Renew TTL when unarchiving ephemeral channels ([#902](https://github.com/block/buzz/pull/902)) ([`732e23dd5`](https://github.com/block/buzz/commit/732e23dd5c37becd0ae24f4afdca757ff0b85264))
|
||||
|
||||
|
||||
## v0.3.13
|
||||
|
||||
- Collapse channel header actions ([#901](https://github.com/block/buzz/pull/901)) ([`ecca5e77e`](https://github.com/block/buzz/commit/ecca5e77e4ec973ddeab1d0f21981d97958515f9))
|
||||
- sprout-agent: make Databricks defaults env-only ([#868](https://github.com/block/buzz/pull/868)) ([`4ec7f8125`](https://github.com/block/buzz/commit/4ec7f8125e845783f3329ab7f2f7193dd2d83938))
|
||||
- Restyle settings sections ([#894](https://github.com/block/buzz/pull/894)) ([`b384354e2`](https://github.com/block/buzz/commit/b384354e2bd110a511ed8d9d045419939f4d1d2b))
|
||||
- Add emoji reaction particles ([#890](https://github.com/block/buzz/pull/890)) ([`fdcbb696f`](https://github.com/block/buzz/commit/fdcbb696fe0797fe9777da7db3116df366cb54ea))
|
||||
- Move settings into the app shell ([#893](https://github.com/block/buzz/pull/893)) ([`32039b9a2`](https://github.com/block/buzz/commit/32039b9a25b4f7feb625662391f09b55fe6090ca))
|
||||
- Tune chat text sizing ([#891](https://github.com/block/buzz/pull/891)) ([`45f3dfe5b`](https://github.com/block/buzz/commit/45f3dfe5ba62bfda2d1279814e52f5954b489a28))
|
||||
- Style channel header navigation ([#889](https://github.com/block/buzz/pull/889)) ([`29f6ccf9e`](https://github.com/block/buzz/commit/29f6ccf9e9c54acc05d106e0be33bb366ebf3f02))
|
||||
- fix: rename missed known_acp_provider_exact → known_acp_runtime_exact ([#900](https://github.com/block/buzz/pull/900)) ([`2ebe55174`](https://github.com/block/buzz/commit/2ebe55174106164dc56834f223c549b7a073b0aa))
|
||||
- chore(deps): update radix-ui-primitives monorepo ([#898](https://github.com/block/buzz/pull/898)) ([`97bdb79de`](https://github.com/block/buzz/commit/97bdb79ded545dc7c91b1dfd6a01e32f631b8df9))
|
||||
- chore(deps): update actions/checkout digest to df4cb1c ([#897](https://github.com/block/buzz/pull/897)) ([`4a93100e1`](https://github.com/block/buzz/commit/4a93100e199a2c7c623d4b2db0e11b26684330c9))
|
||||
- refactor: rename ACP "provider" to "runtime" across the codebase ([#783](https://github.com/block/buzz/pull/783)) ([`0a6067ca1`](https://github.com/block/buzz/commit/0a6067ca1bebd48b84e6fcd63db12d255a10071f))
|
||||
- Unify avatar radius ([#892](https://github.com/block/buzz/pull/892)) ([`056b87d3d`](https://github.com/block/buzz/commit/056b87d3da4584cef3b9d8d8e1807731fb16ea64))
|
||||
|
||||
|
||||
## v0.3.12
|
||||
|
||||
- Show hover cards for inline message emoji ([#885](https://github.com/block/buzz/pull/885)) ([`1b7b6978f`](https://github.com/block/buzz/commit/1b7b6978fc83dc2001ce0a04380c978bd57f7a7e))
|
||||
- Fix monotonic read-state merges ([#884](https://github.com/block/buzz/pull/884)) ([`5268fac2d`](https://github.com/block/buzz/commit/5268fac2d840bb77f2c64d358ef91fa173f8295f))
|
||||
- Refine sidebar behavior and borders ([#869](https://github.com/block/buzz/pull/869)) ([`0a4783c6f`](https://github.com/block/buzz/commit/0a4783c6f8afb02355bded483cecf2a2eb3abc68))
|
||||
- fix(presence): clear on disconnect, fix heartbeat/TTL, drop broken REST path ([#877](https://github.com/block/buzz/pull/877)) ([`5d7c74896`](https://github.com/block/buzz/commit/5d7c74896989938def2d54637cad21bd1e18d0c1))
|
||||
- fix(cli): publish ephemeral events over WebSocket via sprout-ws-client ([#876](https://github.com/block/buzz/pull/876)) ([`ef98ae942`](https://github.com/block/buzz/commit/ef98ae942a5768153c72fca8c38c0255375f46b5))
|
||||
- docs(sprout-acp): add communication discipline rules to base prompt + deprecate --mention flag ([#883](https://github.com/block/buzz/pull/883)) ([`2f50011bd`](https://github.com/block/buzz/commit/2f50011bdd25406ff47140b70b020ccf3cfbf6be))
|
||||
- Polish thread summaries and reactions ([#881](https://github.com/block/buzz/pull/881)) ([`5c2476a71`](https://github.com/block/buzz/commit/5c2476a71e45e6c94fcb6d586e6a3658feb6b262))
|
||||
- feat(cli): add emoji export and import subcommands ([#882](https://github.com/block/buzz/pull/882)) ([`7129cd6f2`](https://github.com/block/buzz/commit/7129cd6f23e4191fc8e4f092da91324982b3edec))
|
||||
- Polish message row hover states ([#880](https://github.com/block/buzz/pull/880)) ([`b84f8e6a0`](https://github.com/block/buzz/commit/b84f8e6a010b837608f0e7c755e82a38367c4a73))
|
||||
- Improve emoji naming and custom emoji UX ([#878](https://github.com/block/buzz/pull/878)) ([`bc5300867`](https://github.com/block/buzz/commit/bc53008676fc9b67209a809167d8f3fb52c0c03a))
|
||||
- docs: add ecosystem section to CONTRIBUTING.md, fix stale release info ([#873](https://github.com/block/buzz/pull/873)) ([`581c7e95a`](https://github.com/block/buzz/commit/581c7e95a9b0ee847d20cdccc803c1f773a89621))
|
||||
- fix(relay): wire custom filter fields through HTTP bridge ([#864](https://github.com/block/buzz/pull/864)) ([`031152221`](https://github.com/block/buzz/commit/031152221bdbb0cad9f9046f2ea999912463427f))
|
||||
- chore: deprecate sprout-mcp — fill CLI gaps, remove crate and all references ([#850](https://github.com/block/buzz/pull/850)) ([`f1c672fea`](https://github.com/block/buzz/commit/f1c672fea53de252055d96bb76685a5bef731b9b))
|
||||
- Fix custom emoji status in profile popover ([#874](https://github.com/block/buzz/pull/874)) ([`5bdac0566`](https://github.com/block/buzz/commit/5bdac0566fb8e73be99cfc4540af12f68a90448f))
|
||||
- fix(agent): gate handoff on provider token usage, not byte estimate ([#821](https://github.com/block/buzz/pull/821)) ([`b295f51c9`](https://github.com/block/buzz/commit/b295f51c904946e78071ac6e45297396c88ee87f))
|
||||
- docs: add VISION_MESH.md — the compute-commons vision ([#867](https://github.com/block/buzz/pull/867)) ([`cdb7bc27e`](https://github.com/block/buzz/commit/cdb7bc27e1183e7616b477877f2e8273ff26db8c))
|
||||
- fix(desktop): simplify profile popover header ([#853](https://github.com/block/buzz/pull/853)) ([`d4bb7f66e`](https://github.com/block/buzz/commit/d4bb7f66e0df89682ceff0765f57b5e27f1ef8b3))
|
||||
- fix(desktop): remove thread comment hover outline ([#861](https://github.com/block/buzz/pull/861)) ([`ccff5464a`](https://github.com/block/buzz/commit/ccff5464a41fce777d7f00fab15fec459bbac322))
|
||||
- feat(desktop): always show channel section search/add buttons ([#856](https://github.com/block/buzz/pull/856)) ([`ad7ab482e`](https://github.com/block/buzz/commit/ad7ab482eb16bed63209d473110a2006a2e6caeb))
|
||||
|
||||
|
||||
## v0.3.11
|
||||
|
||||
- fix(mobile+desktop): cross-device read state sync + diagnostic logging ([#843](https://github.com/block/buzz/pull/843)) ([`269b35e8d`](https://github.com/block/buzz/commit/269b35e8de7b2cb6b6908b1bddee9460774b2802))
|
||||
- feat(mobile): star channels (Slack-style favorites) ([#863](https://github.com/block/buzz/pull/863)) ([`3ddfe5fcc`](https://github.com/block/buzz/commit/3ddfe5fcc25992bd0733d5013589a3521c9989f0))
|
||||
- feat: desktop-screenshot skill to stop agents uploading relay media to PRs ([#862](https://github.com/block/buzz/pull/862)) ([`36d7dbd7c`](https://github.com/block/buzz/commit/36d7dbd7cae45311252a6c10bdf915c0a3dd3e44))
|
||||
- feat(desktop): star channels (Slack-style favorites) ([#860](https://github.com/block/buzz/pull/860)) ([`c10b4f8f5`](https://github.com/block/buzz/commit/c10b4f8f5c646a5d89dfc29bb14ca1c0aa11faea))
|
||||
- fix(desktop): handle symlinked persona pack directories ([#859](https://github.com/block/buzz/pull/859)) ([`f748f7126`](https://github.com/block/buzz/commit/f748f71268eb4f61e0e1dde5d216068b76e69804))
|
||||
- feat: channel muting for desktop and mobile ([#838](https://github.com/block/buzz/pull/838)) ([`1fe7bf287`](https://github.com/block/buzz/commit/1fe7bf2872561d2e2f36b0db4202611fb665f676))
|
||||
- feat(acp): default SPROUT_ACP_MEMORY to on ([#854](https://github.com/block/buzz/pull/854)) ([`4ead7de46`](https://github.com/block/buzz/commit/4ead7de46301efa4743df5b4c5f06374de537c6c))
|
||||
- fix(desktop): eliminate image-hover layout jump in messages ([#813](https://github.com/block/buzz/pull/813)) ([`759e5cd92`](https://github.com/block/buzz/commit/759e5cd923544c307b5b81992ae6010df437bdd2))
|
||||
|
||||
|
||||
## v0.3.10
|
||||
|
||||
- fix(desktop): harden relay mesh connect p-tag ([#834](https://github.com/block/buzz/pull/834)) ([`34ac3ba1d`](https://github.com/block/buzz/commit/34ac3ba1d1825e1dfbe5d81dd67e9b7375b29785))
|
||||
- fix(desktop): scroll activity panel to bottom on open ([#848](https://github.com/block/buzz/pull/848)) ([`b3aefae15`](https://github.com/block/buzz/commit/b3aefae152ebc68a7f8d8720396f3e6ec06c947b))
|
||||
- Polish desktop profile menu interactions ([#836](https://github.com/block/buzz/pull/836)) ([`a13691b62`](https://github.com/block/buzz/commit/a13691b6207fedb1b663ec1458bdea76bb6ec64c))
|
||||
- fix(desktop): outline thread hover targets ([#845](https://github.com/block/buzz/pull/845)) ([`0d9b8148f`](https://github.com/block/buzz/commit/0d9b8148f86e39651871e438cad4b17560d5ffef))
|
||||
- fix(desktop): keep message actions hover-only ([#844](https://github.com/block/buzz/pull/844)) ([`b3be9ecba`](https://github.com/block/buzz/commit/b3be9ecba70f17c12096e52489a6f7de3e00cd56))
|
||||
- fix(desktop): let inbox composer fill available width ([#841](https://github.com/block/buzz/pull/841)) ([`db46c4254`](https://github.com/block/buzz/commit/db46c4254636561fcbec6f63478997102caab770))
|
||||
- fix: use immutable commit-SHA URLs in screenshot PR comments ([#842](https://github.com/block/buzz/pull/842)) ([`2a0572c0d`](https://github.com/block/buzz/commit/2a0572c0d8e4d85afd58e161ade2f927bf19d76d))
|
||||
- feat(mobile+desktop): two-tier Slack-style app icon badge ([#802](https://github.com/block/buzz/pull/802)) ([`3b78dc569`](https://github.com/block/buzz/commit/3b78dc5690b915822fdbe82c20b1bd565ab26108))
|
||||
- chore: simplify file-size check to a flat 1000-line limit ([#839](https://github.com/block/buzz/pull/839)) ([`0c225f4d7`](https://github.com/block/buzz/commit/0c225f4d7d32d79ba38fb154fa878564d6f851de))
|
||||
- fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions ([#837](https://github.com/block/buzz/pull/837)) ([`d8b602a35`](https://github.com/block/buzz/commit/d8b602a359508c816bb7bee468a3e5eb389e3f81))
|
||||
- feat(desktop): reusable screenshot workflow for agents ([#826](https://github.com/block/buzz/pull/826)) ([`06bc67fd3`](https://github.com/block/buzz/commit/06bc67fd342b658bd3ade26d23ac9d63bfb0392a))
|
||||
- desktop(mesh-llm): let a serving node route a different model ([#833](https://github.com/block/buzz/pull/833)) ([`9f0c22a43`](https://github.com/block/buzz/commit/9f0c22a43a6b13030e0e0f75e25fca9983710a18))
|
||||
|
||||
|
||||
## v0.3.9
|
||||
|
||||
- fix: native arbitrary-file download + image context-menu flash ([#830](https://github.com/block/buzz/pull/830)) ([`82ae85f79`](https://github.com/block/buzz/commit/82ae85f79a9e687b51a599f44cf8e8b47b756b80))
|
||||
- fix(desktop): custom emoji reaction rendering + picker autofocus ([#831](https://github.com/block/buzz/pull/831)) ([`7797ae77f`](https://github.com/block/buzz/commit/7797ae77f6412972a95a9f3ffd90555fbd2d1051))
|
||||
- Mesh-LLM v1: relay-gated direct-iroh inference between users (WAN) ([#822](https://github.com/block/buzz/pull/822)) ([`33cfc8529`](https://github.com/block/buzz/commit/33cfc8529325b99ebdf4afc1c4761a9ed87dad86))
|
||||
|
||||
|
||||
## v0.3.8
|
||||
|
||||
|
||||
|
||||
## v0.3.7
|
||||
|
||||
- feat: custom emoji — user-owned NIP-30 sets with a client-side union ([#816](https://github.com/block/buzz/pull/816)) ([`234942130`](https://github.com/block/buzz/commit/2349421304d84b0b2537bebf2cf7923a1ce30e27))
|
||||
- Install sprout-cli skill at repo root + fix desktop clippy ([#818](https://github.com/block/buzz/pull/818)) ([`7a12e5051`](https://github.com/block/buzz/commit/7a12e50518ec51db23c83301b99c66e9bbfdb95f))
|
||||
- fix(desktop): use public re-export path for ensure_client_node_for_model ([#824](https://github.com/block/buzz/pull/824)) ([`2ea3fd88d`](https://github.com/block/buzz/commit/2ea3fd88d23966985e06af494a556fbdd4efc300))
|
||||
- refactor(desktop): feature-gate mesh-llm-sdk behind optional Cargo feature ([#823](https://github.com/block/buzz/pull/823)) ([`fb514c891`](https://github.com/block/buzz/commit/fb514c8918d06b1b76e16c78fa6397c2871d80b4))
|
||||
- fix(desktop): align workflow read/save commands to the frontend contract ([#820](https://github.com/block/buzz/pull/820)) ([`b72eee365`](https://github.com/block/buzz/commit/b72eee365f02465334869c5e48fd6136a829d39c))
|
||||
- fix(desktop): disable mesh-llm auto-build to prevent git config corruption ([#819](https://github.com/block/buzz/pull/819)) ([`5b572d6f5`](https://github.com/block/buzz/commit/5b572d6f5e94ff4d8199a4da40342f301a2896e6))
|
||||
- fix(desktop): clear clippy lints in agents/mesh_llm commands ([#817](https://github.com/block/buzz/pull/817)) ([`192388a3c`](https://github.com/block/buzz/commit/192388a3cbf7df254e0117be8459574282f52deb))
|
||||
- fix(desktop): let channel members add members and bots without admin ([#815](https://github.com/block/buzz/pull/815)) ([`41a3fc158`](https://github.com/block/buzz/commit/41a3fc1589bed81d0ae856036dcf4676b53e1969))
|
||||
- Desktop #806 follow-ups: panel/inbox fixes + top-bar backdrop ([#814](https://github.com/block/buzz/pull/814)) ([`a25ca5d1b`](https://github.com/block/buzz/commit/a25ca5d1bfbc0bfb7c8a2a2c0ca9bb093423d09e))
|
||||
- Fix desktop right-side panel chrome overlap ([#806](https://github.com/block/buzz/pull/806)) ([`5bed17a11`](https://github.com/block/buzz/commit/5bed17a1173457d61760cfb5a7ba6840e83ec0cd))
|
||||
- Sprout × mesh-llm: in-process mesh node (serve/consume) + relay admission ([#798](https://github.com/block/buzz/pull/798)) ([`ede8ddb42`](https://github.com/block/buzz/commit/ede8ddb425ccc09130949752e374a8bcaf842ad1))
|
||||
- fix(desktop): resolve flaky integration tests via project-level assertion timeout ([#812](https://github.com/block/buzz/pull/812)) ([`6481428e2`](https://github.com/block/buzz/commit/6481428e2b9a505c771277478b50b01a01242c5e))
|
||||
|
||||
|
||||
## v0.3.6
|
||||
|
||||
- feat(mobile): add channel sections with relay sync ([#800](https://github.com/block/buzz/pull/800)) ([`5cbedb180`](https://github.com/block/buzz/commit/5cbedb180af56274a518c5233e4de918313bb8d1))
|
||||
- feat(desktop): sync channel sections across devices via Nostr ([#792](https://github.com/block/buzz/pull/792)) ([`753d0fe26`](https://github.com/block/buzz/commit/753d0fe264d4d997f8291f801547242420ba7d97))
|
||||
- feat(media): support arbitrary file types with download cards ([#810](https://github.com/block/buzz/pull/810)) ([`2b052eb46`](https://github.com/block/buzz/commit/2b052eb465f305fc02a535e96012665da34d4d6b))
|
||||
- feat(desktop): add user-defined channel sections to sidebar ([#789](https://github.com/block/buzz/pull/789)) ([`247ac5239`](https://github.com/block/buzz/commit/247ac5239151af6537ae7a16c43c7e99bbe4463e))
|
||||
- feat(desktop): keyboard shortcuts — ⌘⇧N new channel + ↑-to-edit last message ([#809](https://github.com/block/buzz/pull/809)) ([`d810608d8`](https://github.com/block/buzz/commit/d810608d85926146e5d37890635919c022e761a0))
|
||||
- fix(desktop): scope agent sweep to the owning app instance ([#808](https://github.com/block/buzz/pull/808)) ([`39911e428`](https://github.com/block/buzz/commit/39911e42859df41d8ea88195c82e010eb3d98283))
|
||||
- fix(desktop): route notification clicks to thread context ([#790](https://github.com/block/buzz/pull/790)) ([`f2c266bac`](https://github.com/block/buzz/commit/f2c266bac2349f8d5e34c22d42ef0e55c227f36d))
|
||||
- chore(deps): update all non-major dependencies ([#804](https://github.com/block/buzz/pull/804)) ([`33e37de6e`](https://github.com/block/buzz/commit/33e37de6e37cbb73e1ac2ef2499a280f6fcce3a8))
|
||||
- fix(deps): re-pin isomorphic-git patch to 1.38.3 ([#807](https://github.com/block/buzz/pull/807)) ([`5670ffc6a`](https://github.com/block/buzz/commit/5670ffc6a6ec7d6ae6b7b2be4006e23580a10801))
|
||||
- chore(deps): update dependency @tanstack/react-query to v5.100.14 ([#805](https://github.com/block/buzz/pull/805)) ([`033d92f10`](https://github.com/block/buzz/commit/033d92f103ea74fd577f0c2cacc845b1399a5c23))
|
||||
- Fix desktop glass chrome and inbox previews ([#793](https://github.com/block/buzz/pull/793)) ([`bc23620fc`](https://github.com/block/buzz/commit/bc23620fcfae05813b5767d6b0eb830ab108b2b4))
|
||||
- refactor(just): slim down mobile-dev to just run Flutter ([#801](https://github.com/block/buzz/pull/801)) ([`9b9cf4612`](https://github.com/block/buzz/commit/9b9cf461278f0d554523f76cbf80f49e80980bdf))
|
||||
- refactor: consolidate infra management into justfile + add mobile-dev ([#797](https://github.com/block/buzz/pull/797)) ([`2a0385152`](https://github.com/block/buzz/commit/2a03851527f2fad199581e49ea5168cc7304b090))
|
||||
|
||||
|
||||
## v0.3.5
|
||||
|
||||
- feat(mobile): Pulse polish — flat feed, compose page, shared filter chips, like + accent fixes ([#796](https://github.com/block/buzz/pull/796)) ([`b82042090`](https://github.com/block/buzz/commit/b820420909d225b18c45f1bd330a00a2edbee09b))
|
||||
- feat(desktop): add standalone Playwright screenshot helper ([#795](https://github.com/block/buzz/pull/795)) ([`10f37e4ca`](https://github.com/block/buzz/commit/10f37e4cab244c6da319bbad803d65cf3981b794))
|
||||
- feat(sprout-agent): load AGENTS.md and SKILL.md into system prompt ([#762](https://github.com/block/buzz/pull/762)) ([`f34a21d32`](https://github.com/block/buzz/commit/f34a21d32ff31e59a1ff2c11ce4857a483a0a806))
|
||||
- feat: add code block support to message composer ([#788](https://github.com/block/buzz/pull/788)) ([`85861f33f`](https://github.com/block/buzz/commit/85861f33fe006226b59ab8273d7c357af09a781d))
|
||||
- fix(desktop): reap orphaned agent processes on shutdown and restart ([#787](https://github.com/block/buzz/pull/787)) ([`7beb0f8e6`](https://github.com/block/buzz/commit/7beb0f8e6855c2cbef3c9e6310cff5a7368bdd33))
|
||||
|
||||
|
||||
## v0.3.4
|
||||
|
||||
- Update desktop navigation chrome and search ([#779](https://github.com/block/buzz/pull/779)) ([`d77b111b1`](https://github.com/block/buzz/commit/d77b111b1531a7f2992ab7b60edb975549707b4f))
|
||||
- feat(desktop): reload webview on Cmd/Ctrl+R ([#785](https://github.com/block/buzz/pull/785)) ([`5ee2cd051`](https://github.com/block/buzz/commit/5ee2cd05173d5bb5a8711753394ed23ab82f0f4e))
|
||||
- fix(desktop): sync persona pack directory across worktree instances ([#782](https://github.com/block/buzz/pull/782)) ([`fa7febe40`](https://github.com/block/buzz/commit/fa7febe40f57d2bbf019d42e0079618625783ce7))
|
||||
|
||||
|
||||
## v0.3.3
|
||||
|
||||
- fix(release): sync release tags during preflight ([#780](https://github.com/block/buzz/pull/780)) ([`c761a76ff`](https://github.com/block/buzz/commit/c761a76ff2d5789c57159c46bcf08fd02e16d82a))
|
||||
- feat(desktop): thread-aware notifications with mutable follow/mute controls ([#761](https://github.com/block/buzz/pull/761)) ([`3f3ec6479`](https://github.com/block/buzz/commit/3f3ec64791b7cd40ddaa8d8332e00e6f11c09a8d))
|
||||
- fix(desktop): improve model picker message and runtime dropdown clarity ([#778](https://github.com/block/buzz/pull/778)) ([`03e678cfb`](https://github.com/block/buzz/commit/03e678cfbcaa04d5bcfe11ff151c47e5007ba247))
|
||||
- desktop: float unread indicator + fix sidebar scroll jump ([#777](https://github.com/block/buzz/pull/777)) ([`9db8f6ccb`](https://github.com/block/buzz/commit/9db8f6ccb887c67b90fcd6c9972aaa423b34c696))
|
||||
- chore(hooks): standardize check/fix convention with auto-fix pre-commit ([#776](https://github.com/block/buzz/pull/776)) ([`3fbee555f`](https://github.com/block/buzz/commit/3fbee555f067ef80a75bfc52388a40bc336bf6e8))
|
||||
- web: clickable repo tree + per-file blob viewer ([#773](https://github.com/block/buzz/pull/773)) ([`0f89ad169`](https://github.com/block/buzz/commit/0f89ad16925038db5d22e8b15ab7f69898c77b56))
|
||||
- fix(agent): keep parallel tool-result messages contiguous on OpenAI Chat (Databricks image fix) ([#770](https://github.com/block/buzz/pull/770)) ([`61297ac80`](https://github.com/block/buzz/commit/61297ac80a30a13e63d4f01178aec1d33573b193))
|
||||
- fix(release): fetch tags so changelog tracks versions correctly ([#775](https://github.com/block/buzz/pull/775)) ([`5f2423c23`](https://github.com/block/buzz/commit/5f2423c231e393dbc870c75be31f9ea61d9d6328))
|
||||
|
||||
|
||||
## v0.3.2
|
||||
|
||||
- feat(mobile): add Pulse social feed tab ([#772](https://github.com/block/buzz/pull/772)) ([`1218572fa`](https://github.com/block/buzz/commit/1218572fa0380b5517a3c6a2aff34bdc51fa90e6))
|
||||
- feat(sidebar): add More unread floating buttons ([#771](https://github.com/block/buzz/pull/771)) ([`fec6a683e`](https://github.com/block/buzz/commit/fec6a683ea88c25c76f29d5eb4d39709765a791b))
|
||||
- chore: improve markdown spacing ([#766](https://github.com/block/buzz/pull/766)) ([`8eedec740`](https://github.com/block/buzz/commit/8eedec74030ce9cc32366def02c933267f853e80))
|
||||
- fix: prevent inline links rendering in 2-column grid layout ([#767](https://github.com/block/buzz/pull/767)) ([`835a44aad`](https://github.com/block/buzz/commit/835a44aad03e782eeb76d716062767617b47e51b))
|
||||
|
||||
## v0.3.1
|
||||
|
||||
- [codex] Default release command to patch bump ([#768](https://github.com/block/buzz/pull/768)) ([`4222a758c`](https://github.com/block/buzz/commit/4222a758c6491fbbae113e862eb732c769a3dcf8))
|
||||
- Polish desktop Pulse and Home views ([#764](https://github.com/block/buzz/pull/764)) ([`30654e95f`](https://github.com/block/buzz/commit/30654e95f6550854a16ce46bfbbd48dbcaa1b645))
|
||||
|
||||
## v0.3.0
|
||||
|
||||
- Initial release on the automated pipeline. Unifies OSS and internal version numbering above both v0.0.21 (OSS) and v0.2.38 (internal).
|
||||
@@ -1,133 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**conduct@buzz-relay.org**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
453
CONTRIBUTING.md
453
CONTRIBUTING.md
@@ -1,453 +0,0 @@
|
||||
# Contributing to Buzz
|
||||
|
||||
Welcome, and thank you for your interest in contributing! Buzz is an
|
||||
open-source project and we're glad you're here. This guide will help you
|
||||
get from zero to a merged pull request.
|
||||
|
||||
If you have questions that aren't answered here, open a GitHub Discussion or
|
||||
reach out in the community channels.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Code of Conduct](#code-of-conduct)
|
||||
2. [Setting Up the Development Environment](#setting-up-the-development-environment)
|
||||
3. [Running Tests](#running-tests)
|
||||
4. [Code Style](#code-style)
|
||||
5. [Making a Pull Request](#making-a-pull-request)
|
||||
6. [Architecture Overview](#architecture-overview)
|
||||
7. [Ecosystem](#ecosystem)
|
||||
8. [How to Add a New Event Kind](#how-to-add-a-new-event-kind)
|
||||
9. [How to Add a New MCP Tool](#how-to-add-a-new-mcp-tool)
|
||||
10. [How to Add a New API Endpoint](#how-to-add-a-new-api-endpoint)
|
||||
11. [License and CLA](#license-and-cla)
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project follows the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md).
|
||||
By participating you agree to uphold these standards. Please report
|
||||
unacceptable behavior to **conduct@buzz-relay.org**.
|
||||
|
||||
---
|
||||
|
||||
## Setting Up the Development Environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|------|---------|-------|
|
||||
| Rust | 1.88+ | Install via [rustup](https://rustup.rs/) |
|
||||
| Node.js | 24+ | Required for desktop app commands and `just ci` |
|
||||
| pnpm | 10+ | Required for desktop app commands and `just ci` |
|
||||
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
|
||||
| Docker | 24+ | For Postgres, Redis, MinIO |
|
||||
| `just` | latest | Task runner — `cargo install just` |
|
||||
| `lefthook` | latest | Optional; run `lefthook install` for local Git hooks |
|
||||
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
|
||||
|
||||
This repo uses [Hermit](https://cashapp.github.io/hermit/) for toolchain
|
||||
pinning. Activate it once per shell session:
|
||||
|
||||
```bash
|
||||
. ./bin/activate-hermit
|
||||
```
|
||||
|
||||
Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in
|
||||
`bin/`. Each tool is downloaded on first use. You can also run `just bootstrap`
|
||||
(which `just setup` calls automatically) to pre-download all required tools
|
||||
upfront. If you don't use Hermit, ensure your toolchain meets the minimum
|
||||
versions in the table above.
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/block/buzz.git
|
||||
cd buzz
|
||||
|
||||
# 2. Activate Hermit (optional but recommended)
|
||||
. ./bin/activate-hermit
|
||||
|
||||
# 3. Bootstrap tools + infrastructure
|
||||
just setup
|
||||
|
||||
# 4. Install Git hooks (optional, recommended)
|
||||
just hooks
|
||||
```
|
||||
|
||||
`just setup` runs `just bootstrap` first — it copies `.env.example` to `.env`
|
||||
if it doesn't already exist, and invokes `cargo`, `node`, and `pnpm` to trigger
|
||||
Hermit's lazy tool download (each tool is fetched once on first invocation and
|
||||
cached thereafter). You can also run `just bootstrap` independently at any time;
|
||||
it is safe to re-run.
|
||||
|
||||
`just setup` then starts Docker services (Postgres on `:5432`, Redis on `:6379`,
|
||||
Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
|
||||
`:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all
|
||||
pending database migrations.
|
||||
|
||||
### Running the Relay and Desktop App
|
||||
|
||||
```bash
|
||||
just dev # starts the relay + desktop app in one command
|
||||
```
|
||||
|
||||
`just dev` builds all agent tools, starts the relay (`ws://localhost:3000`) in
|
||||
the background, and launches the Tauri desktop app. The relay process is
|
||||
automatically killed when you quit the app or press Ctrl+C.
|
||||
|
||||
For a split-terminal workflow (relay logs visible separately from Vite output):
|
||||
|
||||
```bash
|
||||
just relay # terminal 1 — relay on ws://localhost:3000
|
||||
just desktop-dev # terminal 2 — Vite dev server only (no Tauri shell)
|
||||
```
|
||||
|
||||
### Stopping / Resetting
|
||||
|
||||
```bash
|
||||
just down # Stop Docker services, keep data
|
||||
just reset # Wipe all dev state and recreate it; installed Buzz is preserved
|
||||
```
|
||||
|
||||
Development desktop state uses separate bundle identifiers
|
||||
(`xyz.block.buzz.app.dev` and per-worktree variants), a separate keyring service
|
||||
(`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those dev-only
|
||||
locations and the local Docker volumes. It does not touch the installed app's
|
||||
`xyz.block.buzz.app` data, `buzz-desktop` keyring service, or `~/.buzz` nest.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Unit Tests (no infrastructure required)
|
||||
|
||||
```bash
|
||||
just test-unit
|
||||
```
|
||||
|
||||
Unit tests are self-contained and run without Docker. They cover event
|
||||
parsing, filter matching, auth logic, workflow YAML parsing, and more.
|
||||
|
||||
### Integration Tests (requires running infrastructure)
|
||||
|
||||
```bash
|
||||
just test
|
||||
```
|
||||
|
||||
Integration tests spin up the relay and exercise the full stack — WebSocket
|
||||
connections, NIP-42 auth, event ingestion, search indexing, and workflow
|
||||
execution. `just test` starts Docker services automatically if they're not
|
||||
already running.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
End-to-end tests live in `crates/buzz-test-client/tests/`:
|
||||
|
||||
- `e2e_relay.rs` — WebSocket relay tests
|
||||
- `e2e_mcp.rs` — MCP tool tests
|
||||
- `e2e_nostr_interop.rs` — Nostr protocol interoperability tests
|
||||
- `e2e_media.rs` — media upload/download tests
|
||||
- `e2e_media_extended.rs` — extended media tests (GIF, image processing)
|
||||
|
||||
Run them with (requires running infrastructure):
|
||||
|
||||
```bash
|
||||
cargo test -p buzz-test-client -- --ignored
|
||||
```
|
||||
|
||||
See `TESTING.md` for the full multi-agent E2E testing guide.
|
||||
|
||||
### CI Gate
|
||||
|
||||
Before opening a PR, run the full CI gate locally:
|
||||
|
||||
```bash
|
||||
just ci
|
||||
# Runs: check + unit tests + desktop build + Tauri check + mobile tests
|
||||
```
|
||||
|
||||
This is the same check that runs in CI. PRs that fail `just ci` will not be
|
||||
merged.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### Formatting
|
||||
|
||||
We use `rustfmt` with default settings. Format your code before committing:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
```
|
||||
|
||||
To check without modifying:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
We use `clippy` with warnings-as-errors:
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
Fix all clippy warnings before submitting a PR. If you believe a warning is
|
||||
a false positive, add a targeted `#[allow(...)]` with a comment explaining
|
||||
why.
|
||||
|
||||
### No Unsafe Code
|
||||
|
||||
All crates enforce `#![deny(unsafe_code)]`. Do not add unsafe blocks. If you
|
||||
believe unsafe is genuinely necessary, open an issue first to discuss the
|
||||
approach.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use `thiserror` for library error types.
|
||||
- Use `anyhow` for binary / application-level error propagation.
|
||||
- Do not use `unwrap()` or `expect()` in production code paths. Use `?` or
|
||||
explicit error handling. `unwrap()` is acceptable in tests.
|
||||
|
||||
### Logging and Tracing
|
||||
|
||||
Use the `tracing` crate for all instrumentation. Prefer structured fields
|
||||
over string interpolation:
|
||||
|
||||
```rust
|
||||
// Good
|
||||
tracing::info!(channel_id = %id, event_kind = kind, "Event ingested");
|
||||
|
||||
// Avoid
|
||||
tracing::info!("Event ingested: channel={id} kind={kind}");
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(mcp): add get_feed_actions tool
|
||||
fix(auth): reject expired NIP-42 challenges
|
||||
docs(agents): document workflow MCP tools
|
||||
refactor(db): extract channel queries into channel.rs
|
||||
test(workflow): add approval gate integration test
|
||||
```
|
||||
|
||||
The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is
|
||||
required. The scope (in parentheses) is optional but encouraged.
|
||||
|
||||
---
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
### Before You Start
|
||||
|
||||
- Check open issues and PRs to avoid duplicate work.
|
||||
- For significant changes, open an issue first to discuss the approach.
|
||||
- For small fixes (typos, doc improvements, obvious bugs), go ahead and open
|
||||
a PR directly.
|
||||
|
||||
### What a Good PR Looks Like
|
||||
|
||||
1. **Focused** — one logical change per PR. If you're fixing a bug and
|
||||
refactoring a module, split them into two PRs.
|
||||
|
||||
2. **Tested** — new behavior has tests. Bug fixes include a regression test.
|
||||
If a test is impractical, explain why in the PR description.
|
||||
|
||||
3. **Documented** — public APIs, new event kinds, new MCP tools, and new
|
||||
config variables are documented. Update `README.md`, `AGENTS.md`, or
|
||||
`VISION.md` as appropriate.
|
||||
|
||||
4. **CI passing** — `just ci` passes locally before you push.
|
||||
|
||||
5. **Clear description** — the PR description explains:
|
||||
- What problem this solves (or what feature it adds)
|
||||
- How it was implemented (key decisions, trade-offs)
|
||||
- How to test it manually (if applicable)
|
||||
- Any follow-up work deferred to a future PR
|
||||
|
||||
### PR Checklist
|
||||
|
||||
```
|
||||
- [ ] `just ci` passes (fmt + clippy + unit tests + mobile)
|
||||
- [ ] Integration tests pass (`just test`)
|
||||
- [ ] New public APIs / tools / endpoints are documented
|
||||
- [ ] No new `unwrap()` in production code paths
|
||||
- [ ] No new `unsafe` blocks
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
- A maintainer will review your PR within a few business days.
|
||||
- Address review comments by pushing new commits (don't force-push during
|
||||
review; it makes it hard to see what changed).
|
||||
- Once approved, a maintainer will squash-merge your PR.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design and
|
||||
[AGENTS.md](AGENTS.md#repo-structure) for the complete crate map. The key
|
||||
design principles:
|
||||
|
||||
**The relay is the single source of truth.** All state flows through the
|
||||
event store. Crates communicate through the database and Redis pub/sub — not
|
||||
through direct function calls across crate boundaries (with the exception
|
||||
of `buzz-core` types, which are shared everywhere).
|
||||
|
||||
**Event kinds are the only switch.** Every action in the system — a message,
|
||||
a reaction, a workflow step, a canvas update — is a Nostr event with a kind
|
||||
integer. Adding a new feature means defining a new kind. No breaking changes
|
||||
to existing clients.
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem
|
||||
|
||||
Buzz is developed across multiple repositories. This repo (`block/buzz`)
|
||||
is the open-source home for all application code — the relay, desktop app,
|
||||
mobile app, CLI, and agent harness. Internal repositories handle
|
||||
enterprise-signed builds and infrastructure deployment.
|
||||
|
||||
See [AGENTS.md § Ecosystem](AGENTS.md#ecosystem) for the full repo table and
|
||||
dependency diagram.
|
||||
|
||||
**External contributors:** Fork `block/buzz`, open a PR, and CI runs
|
||||
automatically. No special access is required.
|
||||
|
||||
**Block team members:** See the internal
|
||||
[sprout-releases CONTRIBUTING.md](https://github.com/squareup/sprout-releases/blob/main/CONTRIBUTING.md)
|
||||
for team access setup, onboarding, and the full repo inventory. See
|
||||
[RELEASING.md](RELEASING.md) for the release process.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Event Kind
|
||||
|
||||
1. **Define the kind constant** in `buzz-core/src/kind.rs`:
|
||||
|
||||
```rust
|
||||
/// My new event kind — description of what it represents.
|
||||
pub const KIND_MY_FEATURE: u32 = 4XXXX;
|
||||
```
|
||||
|
||||
Pick a kind number in the appropriate sub-range defined in `kind.rs`.
|
||||
Check the `ALL_KINDS` array for collisions. Each sub-range is documented
|
||||
with comments in the file.
|
||||
|
||||
2. **Define the payload type** in the appropriate module in `buzz-core/src/`
|
||||
(e.g., alongside `event.rs`) if the content field is structured JSON:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MyFeaturePayload {
|
||||
pub field_one: String,
|
||||
pub field_two: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
3. **Register the kind's required scope** in
|
||||
`crates/buzz-relay/src/handlers/ingest.rs` inside
|
||||
`required_scope_for_kind()`. This controls which auth scope a caller
|
||||
needs to submit the event:
|
||||
|
||||
```rust
|
||||
KIND_MY_FEATURE => Ok(Scope::MessagesWrite),
|
||||
```
|
||||
|
||||
4. **Handle post-storage side effects** by adding a match arm in
|
||||
`crates/buzz-relay/src/handlers/side_effects.rs` inside
|
||||
`handle_side_effects()`:
|
||||
|
||||
```rust
|
||||
KIND_MY_FEATURE => handle_my_feature(event, state).await?,
|
||||
```
|
||||
|
||||
`handle_side_effects()` runs after the event is stored — use it for
|
||||
notifications, cache invalidation, or derived data. If the new kind
|
||||
also needs an HTTP bridge surface (for example, a protocol helper that
|
||||
cannot practically use WebSocket), add a handler in
|
||||
`crates/buzz-relay/src/api/` and register it in
|
||||
`crates/buzz-relay/src/router.rs`.
|
||||
|
||||
5. **Persist to the database** — if the event needs to be queryable, add a
|
||||
handler in `buzz-db/src/` (e.g., `buzz-db/src/my_feature.rs`) with
|
||||
the appropriate `INSERT` and `SELECT` queries.
|
||||
|
||||
6. **Index for search** (if applicable) — Postgres FTS indexes persisted
|
||||
events automatically via the `events.search_tsv` generated column. To
|
||||
exclude a privacy-sensitive kind from search, add it to the `CASE WHEN
|
||||
kind IN (...)` exclusion in the `search_tsv` definition (see the initial
|
||||
schema migration) rather than wiring a separate indexer.
|
||||
|
||||
7. **Audit** — the audit log captures all events automatically; no changes
|
||||
needed unless you need custom audit metadata.
|
||||
|
||||
8. **Write tests** — add a unit test for payload serialization in
|
||||
`buzz-core` and an integration test in `buzz-test-client` that sends
|
||||
the new event kind and verifies the expected behavior.
|
||||
|
||||
9. **Document** — `kind.rs` is the authoritative registry of all kind numbers.
|
||||
Update `README.md` if it's a user-facing feature.
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New API Endpoint
|
||||
|
||||
Prefer a signed Nostr event and the existing WebSocket/`POST /events` ingest
|
||||
path over adding endpoint-specific JSON APIs. The relay intentionally exposes
|
||||
only a narrow HTTP surface: NIP-11/NIP-05 metadata, `/events`, `/query`,
|
||||
`/count`, `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and
|
||||
health probes.
|
||||
|
||||
If an HTTP endpoint is still necessary:
|
||||
|
||||
1. **Define the handler** in the appropriate module under
|
||||
`crates/buzz-relay/src/api/`. Resolve the request tenant before any auth or
|
||||
data lookup, use NIP-98 when the endpoint accepts user credentials, and keep
|
||||
community scoping explicit.
|
||||
|
||||
2. **Register the route** in `crates/buzz-relay/src/router.rs` using the
|
||||
narrowest path possible. Do not add new `/api/*` compatibility routes unless
|
||||
the product decision explicitly calls for one.
|
||||
|
||||
3. **Add database queries** in `buzz-db/src/` only when the endpoint cannot be
|
||||
expressed through the existing event query paths.
|
||||
|
||||
4. **Handle errors** using the `api_error()`, `internal_error()`, and
|
||||
`not_found()` helpers in `buzz-relay/src/api/mod.rs`. Return
|
||||
`(StatusCode, Json<Value>)` tuples.
|
||||
|
||||
5. **Write tests** with the `buzz-test-client` harness in
|
||||
`crates/buzz-test-client/tests/`, covering auth, community scoping, and the
|
||||
relevant success path.
|
||||
|
||||
6. **Document** any public endpoint in `ARCHITECTURE.md` and user-facing docs.
|
||||
|
||||
---
|
||||
|
||||
## License and CLA
|
||||
|
||||
Buzz is licensed under the **Apache License, Version 2.0**. See
|
||||
[LICENSE](LICENSE) for the full text.
|
||||
|
||||
By submitting a pull request, you agree that your contribution is licensed
|
||||
under the Apache 2.0 license and that you have the right to submit it.
|
||||
|
||||
If your employer has rights to intellectual property you create, you may need
|
||||
their sign-off. When in doubt, check with your legal team.
|
||||
|
||||
---
|
||||
|
||||
*Thank you for contributing to Buzz. Every bug report, documentation fix,
|
||||
and code contribution makes the project better for everyone. 🐝*
|
||||
11065
Cargo.lock
generated
11065
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
171
Cargo.toml
171
Cargo.toml
@@ -1,171 +0,0 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/buzz-relay",
|
||||
"crates/buzz-core",
|
||||
"crates/buzz-conformance",
|
||||
"crates/buzz-push-gateway",
|
||||
"crates/buzz-db",
|
||||
"crates/buzz-pubsub",
|
||||
"crates/buzz-auth",
|
||||
"crates/buzz-search",
|
||||
"crates/buzz-audit",
|
||||
"crates/buzz-acp",
|
||||
"crates/buzz-agent",
|
||||
"crates/sprig",
|
||||
"crates/buzz-test-client",
|
||||
"crates/buzz-ws-client",
|
||||
"crates/buzz-admin",
|
||||
"crates/buzz-workflow",
|
||||
"crates/buzz-media",
|
||||
"crates/buzz-cli",
|
||||
"crates/buzz-pairing-cli",
|
||||
"crates/buzz-sdk",
|
||||
"crates/buzz-persona",
|
||||
"crates/git-credential-nostr",
|
||||
"crates/git-sign-nostr",
|
||||
"crates/buzz-pair-relay",
|
||||
"crates/buzz-relay-mesh",
|
||||
"crates/buzz-dev-mcp",
|
||||
"examples/countdown-bot",
|
||||
]
|
||||
exclude = ["desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88.0"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/block/sprout"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Runtime
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal", "process"] }
|
||||
tokio-util = { version = "0.7", features = ["rt", "codec"] }
|
||||
|
||||
# HTTP + WebSocket
|
||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||
tower = { version = "0.5", features = ["timeout", "util", "limit"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "timeout", "fs"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.9", features = [
|
||||
"runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"
|
||||
] }
|
||||
|
||||
# Redis
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] }
|
||||
deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] }
|
||||
|
||||
# Nostr
|
||||
nostr = { version = "0.44", features = ["nip44", "nip98"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
postcard = { version = "1", default-features = false, features = ["use-std"] }
|
||||
|
||||
# Inter-relay mesh transport (buzz-relay-mesh)
|
||||
iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
evalexpr = "11"
|
||||
cron = "0.16"
|
||||
# Observability
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tracing-opentelemetry = { version = "0.33" }
|
||||
opentelemetry = { version = "0.32", features = ["trace"] }
|
||||
opentelemetry_sdk = { version = "0.32", features = ["trace", "rt-tokio"] }
|
||||
opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "grpc-tonic", "tls-ring"] }
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.18"
|
||||
metrics-util = "0.20"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# HTTP client (webhook delivery)
|
||||
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
|
||||
|
||||
# Cryptography
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
hmac = "0.13"
|
||||
|
||||
# Randomness
|
||||
rand = "0.10"
|
||||
subtle = "2.6"
|
||||
zeroize = "1.8"
|
||||
|
||||
# Concurrent data structures
|
||||
dashmap = "6"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
|
||||
# Async stream utilities
|
||||
futures-util = "0.3"
|
||||
|
||||
# WebSocket client (test client)
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
url = "2"
|
||||
|
||||
# Property-based testing (dev-only)
|
||||
proptest = "1"
|
||||
|
||||
# MCP SDK (used by buzz-dev-mcp and buzz-agent)
|
||||
rmcp = { version = "1.1.0", features = ["server", "transport-io", "macros"] }
|
||||
schemars = { version = "1", default-features = false }
|
||||
|
||||
# Internal crates
|
||||
buzz-core = { path = "crates/buzz-core" }
|
||||
buzz-conformance = { path = "crates/buzz-conformance" }
|
||||
buzz-db = { path = "crates/buzz-db" }
|
||||
buzz-auth = { path = "crates/buzz-auth" }
|
||||
buzz-pubsub = { path = "crates/buzz-pubsub" }
|
||||
buzz-search = { path = "crates/buzz-search" }
|
||||
buzz-audit = { path = "crates/buzz-audit" }
|
||||
buzz-workflow = { path = "crates/buzz-workflow" }
|
||||
buzz-media = { path = "crates/buzz-media" }
|
||||
buzz-sdk = { path = "crates/buzz-sdk" }
|
||||
buzz-ws-client = { path = "crates/buzz-ws-client" }
|
||||
buzz-relay-mesh = { path = "crates/buzz-relay-mesh" }
|
||||
|
||||
# CI profile — builds the relay for desktop e2e. Dependencies keep full
|
||||
# release optimization (warm from main's cache; they carry the runtime hot
|
||||
# path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for
|
||||
# stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at
|
||||
# roughly half the codegen cost. `incremental` is irrelevant in CI:
|
||||
# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts.
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = false
|
||||
opt-level = 1
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
# Sprig profile — optimized for deploy-anywhere Sprig release artifacts.
|
||||
# Sprig is distributed over the network and installed on fresh hosts, so binary
|
||||
# size matters more than compile speed here. Keep this separate from the normal
|
||||
# `release` profile so desktop/dev release builds do not inherit the slower
|
||||
# size-focused settings unless they opt in explicitly.
|
||||
[profile.sprig]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
# Temporary fork pin: aws-creds 0.39.1 (via rust-s3) cannot read EKS Pod Identity
|
||||
# credentials (AWS_CONTAINER_CREDENTIALS_FULL_URI + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE),
|
||||
# which the relay pod on bb-block requires for S3 media + git storage. This pins
|
||||
# aws-creds to a fork that adopts the aws-creds portion of durch/rust-s3#449
|
||||
# (FULL_URI + token-file + Authorization header, refresh-safe, with a loopback
|
||||
# allowlist for the auth token). Revert to crates.io once #449 lands upstream.
|
||||
[patch.crates-io]
|
||||
aws-creds = { git = "https://github.com/tlongwell-block/rust-s3", rev = "c9fce3620dd434c1f810101d672cf384268dbb0f" }
|
||||
151
Dockerfile
151
Dockerfile
@@ -1,151 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Public Buzz relay image — published as ghcr.io/block/buzz:<tag>.
|
||||
#
|
||||
# Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle
|
||||
# (pnpm + vite), then assembles them into a small debian-slim runtime with
|
||||
# `git` available (the relay shells out to git for repo hydrate / receive-pack
|
||||
# / upload-pack — see crates/buzz-relay/src/api/git).
|
||||
#
|
||||
# Multi-arch is handled by running this same Dockerfile on native amd64 and
|
||||
# native arm64 runners (see .github/workflows/docker.yml). The Dockerfile
|
||||
# itself is platform-agnostic; do not add --platform pins.
|
||||
|
||||
ARG RUST_VERSION=1.95
|
||||
ARG NODE_VERSION=24
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
|
||||
# Optional extra CA bundle for builds behind a TLS-intercepting corporate proxy
|
||||
# (e.g. a Cloudflare/Zscaler gateway that re-signs TLS). Empty by default, so
|
||||
# public CI builds are unaffected. Point it at a PEM file in the build context:
|
||||
# docker build --build-arg EXTRA_CA_CERTS=path/to/proxy-ca.pem ...
|
||||
# Consumed by the network-touching stages below (cargo + pnpm).
|
||||
ARG EXTRA_CA_CERTS=
|
||||
|
||||
# Optional npm registry for builds where the public registry is unreachable or
|
||||
# policy-blocked (e.g. a corporate mirror / Artifactory). Empty default = public
|
||||
# npmjs, so public CI builds are unaffected. Consumed by the web-builder stage.
|
||||
ARG NPM_REGISTRY=
|
||||
|
||||
# ─── Stage 1: cargo-chef base ───────────────────────────────────────────────
|
||||
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
|
||||
# Trust an optional corporate-proxy CA before any network fetch (no-op if unset).
|
||||
ARG EXTRA_CA_CERTS
|
||||
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
|
||||
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
|
||||
cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
|
||||
&& update-ca-certificates \
|
||||
&& echo "CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> /etc/environment; \
|
||||
fi
|
||||
ENV CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt
|
||||
RUN cargo install cargo-chef --locked --version 0.1.71
|
||||
WORKDIR /build
|
||||
|
||||
# ─── Stage 2: plan dependency graph ─────────────────────────────────────────
|
||||
# Only the manifests are needed to compute the recipe; this layer rebuilds
|
||||
# only when Cargo.{toml,lock} or crate manifests change, not on every source
|
||||
# edit.
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# ─── Stage 3: cook dependencies, then build the binary ──────────────────────
|
||||
FROM chef AS builder
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=planner /build/recipe.json recipe.json
|
||||
# Cook the full workspace recipe — relay deps include workspace siblings, so
|
||||
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
COPY . .
|
||||
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
|
||||
-p buzz-admin --bin buzz-admin \
|
||||
-p buzz-pair-relay --bin buzz-pair-relay \
|
||||
&& strip target/release/buzz-relay \
|
||||
&& strip target/release/buzz-admin \
|
||||
&& strip target/release/buzz-pair-relay
|
||||
|
||||
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
|
||||
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
|
||||
# vice versa.
|
||||
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder
|
||||
WORKDIR /build
|
||||
# Trust an optional corporate-proxy CA so corepack + pnpm can fetch over an
|
||||
# intercepting TLS gateway (no-op if EXTRA_CA_CERTS is unset).
|
||||
ARG EXTRA_CA_CERTS
|
||||
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
|
||||
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
# Point npm + corepack at an optional mirror (no-op when NPM_REGISTRY is unset).
|
||||
# corepack reads COREPACK_NPM_REGISTRY to fetch the pinned pnpm; pnpm/npm read
|
||||
# the .npmrc registry for dependency installs.
|
||||
ARG NPM_REGISTRY
|
||||
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
||||
# When using a mirror, disable corepack's npmjs signature check: the mirror
|
||||
# republishes tarballs without the public registry's provenance signatures, so
|
||||
# strict verification fails ("No compatible signature found"). Only relaxed on
|
||||
# the mirror path — public builds (NPM_REGISTRY unset) keep strict verification.
|
||||
RUN if [ -n "${NPM_REGISTRY}" ]; then \
|
||||
echo "registry=${NPM_REGISTRY}" > /build/.npmrc \
|
||||
&& echo "COREPACK_INTEGRITY_KEYS=0" >> /etc/environment; \
|
||||
fi
|
||||
ENV COREPACK_INTEGRITY_KEYS=${NPM_REGISTRY:+0}
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY patches/ patches/
|
||||
COPY web/package.json web/
|
||||
RUN pnpm install --frozen-lockfile --filter buzz-web
|
||||
COPY web/ web/
|
||||
RUN pnpm -C web build
|
||||
|
||||
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
|
||||
# OCI annotations: required for GHCR to auto-link the image to this repo and
|
||||
# inherit its visibility. org.opencontainers.image.source is the load-bearing
|
||||
# one — without it GHCR keeps the image private even when the repo is public.
|
||||
LABEL org.opencontainers.image.title="Buzz" \
|
||||
org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \
|
||||
org.opencontainers.image.source="https://github.com/block/buzz" \
|
||||
org.opencontainers.image.url="https://github.com/block/buzz" \
|
||||
org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1000 buzz \
|
||||
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \
|
||||
--create-home --shell /usr/sbin/nologin buzz
|
||||
|
||||
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
|
||||
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
|
||||
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
||||
|
||||
# The invite landing page is always served from the bundled web UI. Repository
|
||||
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
|
||||
ENV BUZZ_WEB_DIR=/srv/buzz/web
|
||||
|
||||
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
|
||||
EXPOSE 3000 8080 9102
|
||||
|
||||
USER buzz:buzz
|
||||
WORKDIR /var/lib/buzz
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/buzz-relay"]
|
||||
@@ -1,37 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG RUST_VERSION=1.95
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
|
||||
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
|
||||
RUN cargo install cargo-chef --locked --version 0.1.71
|
||||
WORKDIR /build
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=planner /build/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
COPY . .
|
||||
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \
|
||||
&& strip target/release/buzz-push-gateway
|
||||
|
||||
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||
LABEL org.opencontainers.image.title="Buzz Push Gateway" \
|
||||
org.opencontainers.image.description="Capability-gated APNs last hop for Buzz" \
|
||||
org.opencontainers.image.source="https://github.com/block/buzz" \
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1000 buzz \
|
||||
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz --create-home --shell /usr/sbin/nologin buzz
|
||||
COPY --from=builder /build/target/release/buzz-push-gateway /usr/local/bin/buzz-push-gateway
|
||||
EXPOSE 8080 8081
|
||||
USER buzz:buzz
|
||||
WORKDIR /var/lib/buzz
|
||||
ENTRYPOINT ["/usr/local/bin/buzz-push-gateway"]
|
||||
@@ -1 +0,0 @@
|
||||
## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md)
|
||||
847
Justfile
847
Justfile
@@ -1,847 +0,0 @@
|
||||
# Buzz — development task runner
|
||||
|
||||
set dotenv-load := true
|
||||
|
||||
desktop_dir := "desktop"
|
||||
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
|
||||
web_dir := "web"
|
||||
|
||||
# Opt-in mesh-llm. Off by default so `just dev`/`just staging` skip ~420 extra
|
||||
# crates + the llama.cpp native runtime build and stay fast to iterate on.
|
||||
# Turn on to test mesh compute features: `just mesh=1 dev` / `just mesh=1 staging`.
|
||||
mesh := ""
|
||||
|
||||
# List all available tasks
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# ─── Dev Environment ─────────────────────────────────────────────────────────
|
||||
|
||||
# Install required dev tools via Hermit and create .env (safe to re-run)
|
||||
bootstrap:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
# Hermit's bin/ symlinks auto-download pinned tool versions on first use.
|
||||
# Running each tool once triggers the download if not already cached.
|
||||
echo "Ensuring toolchain via Hermit..."
|
||||
cargo --version &
|
||||
node --version &
|
||||
pnpm --version &
|
||||
wait
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Error: Docker is required but not installed."
|
||||
echo "Install it from https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env from .env.example — review it before running just dev."
|
||||
fi
|
||||
|
||||
# Start Docker services, run migrations, install desktop deps
|
||||
setup: bootstrap
|
||||
./scripts/dev-setup.sh
|
||||
|
||||
# Install git hooks via lefthook (dispatches from the shared .git/hooks dir so all
|
||||
# linked worktrees inherit the same hooks without a worktree-relative .hooks path)
|
||||
hooks:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# --path-format=absolute guarantees an absolute path from every invocation context:
|
||||
# without it, --git-common-dir returns ".git" from the main checkout and a
|
||||
# relative hooksPath would break linked-worktree dispatch just like .hooks did.
|
||||
HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir)/hooks"
|
||||
git config --local core.hooksPath "$HOOKS_DIR"
|
||||
lefthook install --force
|
||||
|
||||
# Wipe development state and recreate a clean environment. Installed Buzz is preserved.
|
||||
[confirm("This will DELETE all development data and preserve installed Buzz. Continue? (y/N)")]
|
||||
reset:
|
||||
./scripts/dev-reset.sh --yes
|
||||
|
||||
# Stop all dev services (keep data)
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Show dev service status
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
# Tail all service logs
|
||||
logs *ARGS:
|
||||
docker compose logs -f {{ARGS}}
|
||||
|
||||
# ─── Build & Check ───────────────────────────────────────────────────────────
|
||||
|
||||
# Build the Rust workspace
|
||||
build:
|
||||
cargo build --workspace
|
||||
|
||||
# Build the Rust workspace in release mode
|
||||
build-release:
|
||||
cargo build --workspace --release
|
||||
|
||||
# Run repo lint and formatting checks
|
||||
check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check
|
||||
|
||||
# Format all Rust code
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Check formatting without modifying files
|
||||
fmt-check:
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# Run clippy with warnings as errors
|
||||
clippy:
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Install JS dependencies (pnpm workspace — installs all packages from root)
|
||||
desktop-install:
|
||||
pnpm install
|
||||
|
||||
# Install JS dependencies reproducibly for CI (pnpm workspace)
|
||||
desktop-install-ci:
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Run desktop lint and format checks
|
||||
desktop-check:
|
||||
cd {{desktop_dir}} && pnpm check
|
||||
|
||||
# Fix desktop lint and format issues
|
||||
desktop-fix:
|
||||
cd {{desktop_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
|
||||
|
||||
# Run desktop TS helper unit tests
|
||||
desktop-test:
|
||||
cd {{desktop_dir}} && pnpm test
|
||||
|
||||
# Run desktop TypeScript checks
|
||||
desktop-typecheck:
|
||||
cd {{desktop_dir}} && pnpm typecheck
|
||||
|
||||
# Build desktop frontend assets
|
||||
desktop-build:
|
||||
cd {{desktop_dir}} && pnpm build
|
||||
|
||||
# Format desktop Tauri Rust code
|
||||
desktop-tauri-fmt:
|
||||
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all
|
||||
|
||||
# Check desktop Tauri Rust formatting
|
||||
desktop-tauri-fmt-check:
|
||||
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all -- --check
|
||||
|
||||
# Format all code (Rust + Tauri Rust + Dart)
|
||||
fmt-all: fmt desktop-tauri-fmt mobile-fmt
|
||||
|
||||
# Fix all formatting and lint issues
|
||||
fix-all: fmt desktop-tauri-fmt desktop-fix web-fix mobile-fix
|
||||
|
||||
# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
|
||||
# Sidecar binary list must stay in sync with desktop-release-build below.
|
||||
_ensure-sidecar-stubs:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
|
||||
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||
done
|
||||
|
||||
# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
|
||||
_ensure-services:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
|
||||
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
|
||||
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
|
||||
echo "Services already healthy"
|
||||
exit 0
|
||||
fi
|
||||
echo "Starting services..."
|
||||
docker compose up -d || true
|
||||
echo -n "Waiting for services"
|
||||
for i in $(seq 1 40); do
|
||||
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
|
||||
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
|
||||
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
|
||||
echo " ready"
|
||||
exit 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 3
|
||||
done
|
||||
echo " timed out"
|
||||
exit 1
|
||||
|
||||
# Apply database migrations and seed the local dev community if the dev database is running
|
||||
_ensure-migrations: _ensure-services
|
||||
cargo run -p buzz-admin -- migrate
|
||||
./scripts/seed-local-community.sh
|
||||
|
||||
# Run clippy on the desktop Tauri Rust crate
|
||||
desktop-tauri-clippy: _ensure-sidecar-stubs
|
||||
cargo clippy --manifest-path {{desktop_tauri_manifest}} --all-targets -- -D warnings
|
||||
|
||||
# Check the desktop Tauri Rust crate compiles
|
||||
desktop-tauri-check: _ensure-sidecar-stubs
|
||||
cargo check --manifest-path {{desktop_tauri_manifest}}
|
||||
|
||||
# Run desktop Tauri Rust unit tests
|
||||
desktop-tauri-test: _ensure-sidecar-stubs
|
||||
cd desktop/src-tauri && cargo test
|
||||
|
||||
# Build the full desktop Tauri app locally (unsigned, for testing)
|
||||
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
|
||||
# pnpm install is unconditional here: release builds must start from a clean dep tree.
|
||||
desktop-release-build target="aarch64-apple-darwin":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET={{target}}
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/buzz-$TARGET"
|
||||
pnpm install
|
||||
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
|
||||
|
||||
# Run desktop checks suitable for CI / pre-push
|
||||
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
|
||||
|
||||
# Seed deterministic channel data for desktop Playwright tests
|
||||
desktop-e2e-seed: _ensure-migrations
|
||||
./scripts/setup-desktop-test-data.sh
|
||||
|
||||
# Run desktop browser smoke tests
|
||||
desktop-e2e-smoke:
|
||||
cd {{desktop_dir}} && pnpm test:e2e:smoke
|
||||
|
||||
# Run desktop relay-backed e2e tests
|
||||
desktop-e2e-integration: _ensure-migrations
|
||||
cd {{desktop_dir}} && pnpm test:e2e:integration
|
||||
|
||||
# Run all checks suitable for CI / pre-push (no infra needed)
|
||||
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
|
||||
|
||||
# ─── Test ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run all tests (unit + integration)
|
||||
test:
|
||||
./scripts/run-tests.sh all
|
||||
|
||||
# Run unit tests only (no infra needed)
|
||||
test-unit:
|
||||
#!/usr/bin/env bash
|
||||
if command -v cargo-nextest &>/dev/null; then
|
||||
cargo nextest run -p buzz-core -p buzz-auth --lib
|
||||
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
|
||||
# They guard the embedded-migrator invariant (exactly the consolidated
|
||||
# 0001; cutover/backfill stays an operator script, not startup state)
|
||||
# and the tenant-scoping lints. The Postgres-backed buzz-db tests are
|
||||
# #[ignore]d, so --lib runs only the infra-free set. Without this gate a
|
||||
# stray file in migrations/ or a broken lint ships green.
|
||||
cargo nextest run -p buzz-db --lib
|
||||
# Multi-tenant conformance gate (buzz-conformance): the independent
|
||||
# replay checker + golden fixtures. No infra — pure in-process trace
|
||||
# replay — so it belongs in the unit job. Run all targets (lib + the
|
||||
# tests/replay_fixtures.rs integration test), not just --lib.
|
||||
cargo nextest run -p buzz-conformance
|
||||
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
|
||||
# contract/race tests run in the dedicated CI job below.
|
||||
cargo nextest run -p buzz-push-gateway
|
||||
else
|
||||
./scripts/run-tests.sh unit
|
||||
fi
|
||||
|
||||
# Run integration tests only (starts services if needed)
|
||||
test-integration:
|
||||
./scripts/run-tests.sh integration
|
||||
|
||||
# Buzz shared compute e2e: current desktop discovery/admission logic and
|
||||
# Playwright UI coverage.
|
||||
mesh-e2e:
|
||||
cargo test --manifest-path {{desktop_dir}}/src-tauri/Cargo.toml --features mesh-llm mesh_llm --lib
|
||||
cd {{desktop_dir}} && pnpm test:e2e:smoke -- mesh-compute.spec.ts
|
||||
|
||||
# Reset only development state, seed deterministic local channels, and launch
|
||||
# the mesh-enabled desktop with the repository's public Tyler test identity.
|
||||
# This is for local verification only; never point this identity at staging/prod.
|
||||
[confirm("This will reset development data, preserve installed Buzz, then launch a seeded mesh dev app. Continue? (y/N)")]
|
||||
mesh-dev-fresh:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
./scripts/dev-reset.sh --yes
|
||||
./scripts/setup-desktop-test-data.sh
|
||||
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
|
||||
export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
|
||||
export BUZZ_ALLOW_NIP_OA_AUTH=true
|
||||
export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
|
||||
export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
|
||||
export BUZZ_RECONCILE_CHANNELS=true
|
||||
export BUZZ_RESET_WEBVIEW_STATE=1
|
||||
exec just mesh=1 dev
|
||||
|
||||
# Real serve->client->inference on this machine (not CI).
|
||||
mesh-e2e-hardware:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||
cargo run -p buzz-relay --example mesh_serve_client_smoke
|
||||
|
||||
# Three isolated node processes: trusted member joins and infers; stranger is rejected.
|
||||
# Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain.
|
||||
mesh-e2e-admission:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||
cargo run -p buzz-relay --example mesh_admission_smoke
|
||||
|
||||
# Full hardware confidence suite: routing, owner admission, and real agent inference.
|
||||
mesh-e2e-confidence:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||
cargo build --release -p buzz-agent -p buzz-dev-mcp
|
||||
cargo run -p buzz-relay --example mesh_serve_client_smoke
|
||||
cargo run -p buzz-relay --example mesh_admission_smoke
|
||||
cargo run -p buzz-relay --example mesh_agent_e2e
|
||||
|
||||
# Take desktop screenshots using the mock bridge
|
||||
desktop-screenshot *ARGS:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
just desktop-build
|
||||
cd {{desktop_dir}}
|
||||
if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
|
||||
python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
|
||||
trap "kill $! 2>/dev/null || true" EXIT
|
||||
for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
|
||||
fi
|
||||
node tests/helpers/screenshot.mjs {{ARGS}}
|
||||
|
||||
# ─── Run ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Start the relay server (auto-starts Docker services if needed)
|
||||
relay: bootstrap _ensure-migrations
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
cargo run -p buzz-relay
|
||||
|
||||
# Start the relay with the built web UI served from it
|
||||
relay-web: bootstrap _ensure-migrations
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
pnpm -C web build
|
||||
BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay
|
||||
|
||||
# Start the relay server in release mode
|
||||
relay-release: _ensure-migrations
|
||||
cargo run -p buzz-relay --release
|
||||
|
||||
|
||||
# Run the desktop Tauri app in dev mode with a local relay (ports and identity derived from worktree)
|
||||
dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
|
||||
relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
|
||||
health_port="${BUZZ_HEALTH_PORT:-8080}"
|
||||
metrics_port="${BUZZ_METRICS_PORT:-9102}"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do
|
||||
name="${spec%%:*}"; port="${spec##*:}"
|
||||
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2
|
||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
|
||||
echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
|
||||
if [[ -n "{{mesh}}" ]]; then
|
||||
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||
fi
|
||||
# Docker Desktop's forwarded MinIO port can stall under the deployment
|
||||
# probe's 32 concurrent writers. Keep the gate enabled in local dev, using
|
||||
# the bounded profile already used by the relay test launcher.
|
||||
export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}"
|
||||
export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}"
|
||||
./target/debug/buzz-relay &
|
||||
RELAY_PID=$!
|
||||
cleanup() {
|
||||
[[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true
|
||||
kill "$RELAY_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
relay_ready=false
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$RELAY_PID" 2>/dev/null; then
|
||||
echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2
|
||||
wait "$RELAY_PID" || true
|
||||
exit 1
|
||||
fi
|
||||
if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then
|
||||
relay_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ "$relay_ready" != true ]]; then
|
||||
echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2
|
||||
exit 1
|
||||
fi
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
source ../scripts/instance-env.sh
|
||||
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||
echo "Starting on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||
FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
|
||||
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
|
||||
staging *ARGS: bootstrap _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
pnpm install # unconditional: staging must always start with a clean dep tree
|
||||
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
|
||||
FEATURES=()
|
||||
if [[ -n "{{mesh}}" ]]; then
|
||||
FEATURES=(--features mesh-llm)
|
||||
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||
fi
|
||||
# Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up.
|
||||
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
|
||||
cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}"
|
||||
chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}"
|
||||
cd {{desktop_dir}}
|
||||
export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
|
||||
source ../scripts/instance-env.sh
|
||||
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
|
||||
# agent workers. Reap this instance's agents on exit as a backstop.
|
||||
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
|
||||
echo "Starting staging on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Run the desktop frontend dev server (port derived from worktree)
|
||||
desktop-dev:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
source ../scripts/instance-env.sh
|
||||
echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||
pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort
|
||||
|
||||
# ─── Web ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run the web frontend dev server (port derived from worktree to avoid collisions)
|
||||
web:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
source scripts/instance-env.sh
|
||||
export VITE_PORT=$((BUZZ_VITE_PORT + 100))
|
||||
export VITE_RELAY_URL="${BUZZ_RELAY_URL}"
|
||||
echo "Starting web dev server on port ${VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||
cd {{web_dir}}
|
||||
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
||||
|
||||
# Run web lint and format checks
|
||||
web-check:
|
||||
cd {{web_dir}} && pnpm check
|
||||
|
||||
# Fix web lint and format issues
|
||||
web-fix:
|
||||
cd {{web_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
|
||||
|
||||
# Run web TypeScript checks
|
||||
web-typecheck:
|
||||
cd {{web_dir}} && pnpm typecheck
|
||||
|
||||
# Build web frontend assets
|
||||
web-build:
|
||||
cd {{web_dir}} && pnpm build
|
||||
|
||||
# Run web browser smoke tests
|
||||
web-e2e-smoke:
|
||||
cd {{web_dir}} && pnpm test:e2e:smoke
|
||||
|
||||
# ─── Mobile ──────────────────────────────────────────────────────────────────
|
||||
|
||||
mobile_dir := "mobile"
|
||||
|
||||
# Install mobile Flutter dependencies
|
||||
mobile-install:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter pub get
|
||||
|
||||
# Format all Dart code
|
||||
mobile-fmt:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format .
|
||||
|
||||
# Fix mobile formatting and run analysis
|
||||
mobile-fix:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format . && flutter analyze
|
||||
|
||||
# Run mobile lint and format checks
|
||||
mobile-check:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs
|
||||
|
||||
# Run mobile tests
|
||||
mobile-test:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test
|
||||
|
||||
# Compile an unsigned Android debug APK
|
||||
mobile-build-android:
|
||||
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub
|
||||
|
||||
# Run the mobile app on iOS simulator
|
||||
mobile-dev:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if ! pgrep -x Simulator &>/dev/null; then
|
||||
open -a Simulator
|
||||
sleep 3
|
||||
fi
|
||||
cd {{mobile_dir}}
|
||||
unset GIT_DIR GIT_WORK_TREE
|
||||
flutter run
|
||||
|
||||
# ─── Database ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Apply database migrations
|
||||
migrate: _ensure-migrations
|
||||
|
||||
# ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Remove build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
cargo clean --manifest-path desktop/src-tauri/Cargo.toml
|
||||
|
||||
# Check the Rust workspace compiles without producing binaries
|
||||
check-compile:
|
||||
cargo check --workspace --all-targets
|
||||
|
||||
# ─── Release ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Read the current desktop version from package.json
|
||||
get-current-version:
|
||||
@node -p "require('./desktop/package.json').version"
|
||||
|
||||
# Read the current relay version from its crate manifest
|
||||
get-current-relay-version:
|
||||
@grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/'
|
||||
|
||||
# Compute next minor version (e.g., 0.3.0 → 0.4.0)
|
||||
get-next-minor-version:
|
||||
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')"
|
||||
|
||||
# Compute next patch version (e.g., 0.3.0 → 0.3.1)
|
||||
get-next-patch-version:
|
||||
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
|
||||
|
||||
# Compute next relay patch version (e.g., 0.3.0 → 0.3.1)
|
||||
get-next-relay-patch-version:
|
||||
@python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
|
||||
|
||||
# Read the current mobile version from pubspec.yaml (strips the +build suffix)
|
||||
get-current-mobile-version:
|
||||
@grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/'
|
||||
|
||||
# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1)
|
||||
get-next-mobile-patch-version:
|
||||
@python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
|
||||
|
||||
# Update version in desktop package manifests and regenerate lockfiles
|
||||
bump-desktop-version version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# desktop/package.json
|
||||
cd desktop && npm pkg set "version={{ version }}" && cd ..
|
||||
# desktop/src-tauri/tauri.conf.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'desktop/src-tauri/tauri.conf.json';
|
||||
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
c.version = '{{ version }}';
|
||||
fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
|
||||
"
|
||||
# JSON.stringify expands arrays/objects in a way biome rejects; reformat to match.
|
||||
(cd desktop && pnpm exec biome format --write src-tauri/tauri.conf.json)
|
||||
# desktop/src-tauri/Cargo.toml — only first version line (under [package])
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'desktop/src-tauri/Cargo.toml';
|
||||
let t = fs.readFileSync(p, 'utf8');
|
||||
t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"');
|
||||
fs.writeFileSync(p, t);
|
||||
"
|
||||
# Regenerate lockfiles
|
||||
pnpm install --lockfile-only
|
||||
cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml
|
||||
echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles"
|
||||
|
||||
# Bump the relay crate version and regenerate the lockfile
|
||||
bump-relay-version version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# buzz-relay carries its own `version =` (not version.workspace), so the
|
||||
# replace targets the package version line only.
|
||||
perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml
|
||||
cargo update -p buzz-relay
|
||||
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
|
||||
|
||||
# Bump the mobile pubspec version and regenerate the lockfile
|
||||
bump-mobile-version version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention
|
||||
# (a literal `+1`, matching the desktop lane's prior behavior).
|
||||
perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml
|
||||
(unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get)
|
||||
echo "Bumped mobile to {{ version }} and regenerated pubspec.lock"
|
||||
|
||||
# Open or update the desktop release PR (signed desktop app)
|
||||
release-desktop *ARGS:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARG="{{ ARGS }}"
|
||||
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
|
||||
VERSION=$(just get-next-patch-version)
|
||||
else
|
||||
VERSION="$ARG"
|
||||
fi
|
||||
just _release-pr desktop "$VERSION"
|
||||
|
||||
# Open or update the relay release PR (ghcr.io/block/buzz image)
|
||||
release-relay *ARGS:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARG="{{ ARGS }}"
|
||||
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
|
||||
VERSION=$(just get-next-relay-patch-version)
|
||||
else
|
||||
VERSION="$ARG"
|
||||
fi
|
||||
just _release-pr relay "$VERSION"
|
||||
|
||||
# Open or update the mobile release PR (Buzz mobile app)
|
||||
release-mobile *ARGS:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARG="{{ ARGS }}"
|
||||
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
|
||||
VERSION=$(just get-next-mobile-patch-version)
|
||||
else
|
||||
VERSION="$ARG"
|
||||
fi
|
||||
just _release-pr mobile "$VERSION"
|
||||
|
||||
# Shared release-PR engine. One body, three lanes — the only lane-specific steps
|
||||
# are the version-bump command and the file/tag/changelog identifiers selected
|
||||
# in the `case` below. Everything else (git preflight, branch reset, changelog
|
||||
# generation, commit, push, PR open/edit) is identical across lanes.
|
||||
_release-pr lane version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION="{{ version }}"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
# Lane-specific identifiers. The bump command runs after the branch switch.
|
||||
case "{{ lane }}" in
|
||||
desktop)
|
||||
BRANCH_PREFIX="version-bump"
|
||||
TAG_FETCH='v*'
|
||||
TAG_MATCH='v[0-9]*'
|
||||
TAG_EXCLUDE='*-*'
|
||||
TAG_PREFIX="v"
|
||||
CHANGELOG="CHANGELOG.md"
|
||||
ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md)
|
||||
LOG_PATHS=(desktop/ crates/buzz-core/ crates/buzz-persona/ crates/buzz-sdk/ crates/buzz-agent/)
|
||||
ARTIFACT="Buzz Desktop" ;;
|
||||
relay)
|
||||
BRANCH_PREFIX="relay-release"
|
||||
TAG_FETCH='relay-v*'
|
||||
TAG_MATCH='relay-v[0-9]*'
|
||||
TAG_EXCLUDE='relay-v*-*'
|
||||
TAG_PREFIX="relay-v"
|
||||
CHANGELOG="crates/buzz-relay/CHANGELOG.md"
|
||||
ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
|
||||
LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
|
||||
ARTIFACT="Buzz Relay" ;;
|
||||
mobile)
|
||||
BRANCH_PREFIX="mobile-release"
|
||||
TAG_FETCH='mobile-v*'
|
||||
TAG_MATCH='mobile-v[0-9]*'
|
||||
TAG_EXCLUDE='mobile-v*-*'
|
||||
TAG_PREFIX="mobile-v"
|
||||
CHANGELOG="mobile/CHANGELOG.md"
|
||||
ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md)
|
||||
LOG_PATHS=(mobile/)
|
||||
ARTIFACT="Buzz Mobile" ;;
|
||||
*)
|
||||
echo "Error: unknown release lane '{{ lane }}'"
|
||||
exit 1 ;;
|
||||
esac
|
||||
echo "Preparing ${ARTIFACT} release v${VERSION}..."
|
||||
# Must run on main with a clean, up-to-date tree.
|
||||
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
|
||||
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
|
||||
exit 1
|
||||
fi
|
||||
git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags
|
||||
# Release tags are remote-owned state; sync only this lane's tags so stale
|
||||
# local tags from older histories do not make release preflight fail.
|
||||
git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}"
|
||||
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
||||
echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Error: working tree is dirty. Commit or stash changes first."
|
||||
exit 1
|
||||
fi
|
||||
# Switch to the release branch (create, or reset to main if it exists).
|
||||
BRANCH="${BRANCH_PREFIX}/${VERSION}"
|
||||
if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
echo "Branch '$BRANCH' already exists — resetting to origin/main..."
|
||||
git switch "$BRANCH"
|
||||
git reset --hard origin/main
|
||||
elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
|
||||
echo "Branch '$BRANCH' exists on remote — checking out and resetting to origin/main..."
|
||||
git switch -c "$BRANCH" --track "origin/$BRANCH"
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git switch -c "$BRANCH"
|
||||
fi
|
||||
# Lane-specific bump (the one diverging step).
|
||||
case "{{ lane }}" in
|
||||
desktop) just bump-desktop-version "$VERSION" ;;
|
||||
relay) just bump-relay-version "$VERSION" ;;
|
||||
mobile) just bump-mobile-version "$VERSION" ;;
|
||||
esac
|
||||
# Generate the changelog from commits since this lane's last release tag.
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")
|
||||
REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||
format_log() {
|
||||
local range="$1"
|
||||
git log "$range" --format="%h %H %s" --no-merges -- "${LOG_PATHS[@]}" | while IFS=' ' read -r short full rest; do
|
||||
local pr subject
|
||||
pr=$(printf '%s' "$rest" | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' || true)
|
||||
if [[ -n "$pr" ]]; then
|
||||
subject=$(printf '%s' "$rest" | sed -E 's/ \(#[0-9]+\)$//')
|
||||
printf -- '- %s ([#%s](https://github.com/%s/pull/%s)) ([`%s`](https://github.com/%s/commit/%s))\n' \
|
||||
"$subject" "$pr" "$REPO" "$pr" "$short" "$REPO" "$full"
|
||||
else
|
||||
printf -- '- %s ([`%s`](https://github.com/%s/commit/%s))\n' \
|
||||
"$rest" "$short" "$REPO" "$full"
|
||||
fi
|
||||
done
|
||||
}
|
||||
TMPFILE=$(mktemp)
|
||||
{
|
||||
echo "# Changelog"
|
||||
echo ""
|
||||
echo "## ${TAG_PREFIX}${VERSION}"
|
||||
echo ""
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
format_log "${LAST_TAG}..HEAD"
|
||||
else
|
||||
echo "- Initial release"
|
||||
fi
|
||||
echo ""
|
||||
if [[ -f "$CHANGELOG" ]]; then
|
||||
tail -n +2 "$CHANGELOG"
|
||||
fi
|
||||
} > "$TMPFILE"
|
||||
mkdir -p "$(dirname "$CHANGELOG")"
|
||||
mv "$TMPFILE" "$CHANGELOG"
|
||||
# Commit.
|
||||
git add "${ADD_FILES[@]}"
|
||||
RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}"
|
||||
if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then
|
||||
git commit --amend --no-edit
|
||||
else
|
||||
git commit -m "$RELEASE_MSG"
|
||||
fi
|
||||
# Push and open/update the PR.
|
||||
git push --force-with-lease -u origin "$BRANCH"
|
||||
PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n'
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n'
|
||||
CHANGELOG_BODY=$(format_log "${LAST_TAG}..HEAD~1")
|
||||
MAX_LOG=62000
|
||||
if (( ${#CHANGELOG_BODY} > MAX_LOG )); then
|
||||
TRUNCATED=$(printf '%s' "$CHANGELOG_BODY" | awk -v max="$MAX_LOG" \
|
||||
'BEGIN{n=0} {line_len=length($0)+1; if(n+line_len>max) exit; n+=line_len; print}')
|
||||
SHOWN=$(printf '%s\n' "$TRUNCATED" | grep -c '^-' || true)
|
||||
TOTAL=$(printf '%s\n' "$CHANGELOG_BODY" | grep -c '^-' || true)
|
||||
SKIPPED=$(( TOTAL - SHOWN ))
|
||||
CHANGELOG_BODY="${TRUNCATED}"$'\n'"_… and ${SKIPPED} more commits — [compare ${LAST_TAG}…${TAG_PREFIX}${VERSION}](https://github.com/${REPO}/compare/${LAST_TAG}...${TAG_PREFIX}${VERSION})_"
|
||||
fi
|
||||
PR_BODY+="${CHANGELOG_BODY}"$'\n\n'
|
||||
else
|
||||
PR_BODY+="Initial release."$'\n\n'
|
||||
fi
|
||||
PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically."
|
||||
PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}"
|
||||
EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true)
|
||||
if [[ -n "$EXISTING_PR" ]]; then
|
||||
gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY"
|
||||
PR_URL="$EXISTING_PR"
|
||||
echo ""
|
||||
echo "Updated existing release PR: ${PR_URL}"
|
||||
else
|
||||
PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY")
|
||||
echo ""
|
||||
echo "Release PR opened: ${PR_URL}"
|
||||
fi
|
||||
echo "Merge it to trigger the release build."
|
||||
|
||||
# ─── Agent Harness ────────────────────────────────────────────────────────────
|
||||
|
||||
# Run a goose agent connected to a Buzz relay (foreground)
|
||||
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
|
||||
exec env "${env_args[@]}" ./target/release/buzz-acp
|
||||
|
||||
# Run a goose agent in the background (screen session named 'goose-agent-N')
|
||||
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
|
||||
screen -dmS goose-agent-{{agents}} bash -c "$(printf '%q ' env "${env_args[@]}") ./target/release/buzz-acp"
|
||||
echo "Agent running in screen session 'goose-agent-{{agents}}'. Attach with: screen -r goose-agent-{{agents}}"
|
||||
|
||||
# ─── Benchmarking ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Run the Buzz orchestra benchmark — leaderboard-eligible by default (TB 2.1, k=5, Sonnet+Haiku). Stands up its own Docker stack; --gui opens a live spectator desktop app; other flags pass to benchmark.py (--dataset/--path, --include-task, --attempts, --manifest, --dry-run, ...)
|
||||
benchmark *ARGS:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}}
|
||||
|
||||
# Stop the benchmark Docker stack (state and channels are kept)
|
||||
benchmark-down:
|
||||
docker compose --project-name buzz-benchmark down
|
||||
201
LICENSE
201
LICENSE
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Copyright 2026 Block, Inc.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
356
NOSTR.md
356
NOSTR.md
@@ -1,356 +0,0 @@
|
||||
# Using Third-Party Nostr Clients with Buzz
|
||||
|
||||
Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed.
|
||||
|
||||
## Community scope
|
||||
|
||||
Buzz treats the relay URL/domain as authoritative for the community. Today's
|
||||
single-relay deployment has exactly one community behind that URL, so existing
|
||||
NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and
|
||||
HTTP/media/git paths. In a multi-community deployment, each community is reached
|
||||
by its own domain or subdomain; the backend resolves the community from the host
|
||||
before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic.
|
||||
|
||||
The Nostr wire format does not grow a tenant tag. Client-supplied `#h` tags still
|
||||
name channels/groups and are checked against the host-derived community. Events
|
||||
without `#h` — profiles, gift-wrapped DMs, membership notifications, lists,
|
||||
status, long-form notes, workflow/system events, and other "global" streams — are
|
||||
global only inside the connected community. A pubkey can join multiple
|
||||
communities and repost its profile in each one; DMs and profiles do not inherit
|
||||
across community domains.
|
||||
|
||||
---
|
||||
|
||||
## NIP-29 Direct
|
||||
|
||||
Connect any NIP-29 client straight to the relay.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup
|
||||
export BUZZ_PUBKEY_ALLOWLIST=true
|
||||
|
||||
# 2. Start the relay (auto-starts Docker services and runs migrations)
|
||||
just relay & # relay on :3000
|
||||
|
||||
# 3. Add a pubkey to the allowlist (if enabled)
|
||||
# Insert directly — there is no CLI command for this yet.
|
||||
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
|
||||
|
||||
# 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
|
||||
```
|
||||
|
||||
### What Works
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|:------:|-------|
|
||||
| **Group chat (kind:9)** | ✅ | Send/receive messages with `#h <channel-uuid>` tag |
|
||||
| **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) |
|
||||
| **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required |
|
||||
| **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. |
|
||||
| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility` and `channel_type` |
|
||||
| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. |
|
||||
| **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. |
|
||||
| **Edit group metadata (kind:9002)** | ✅ | `name`/`about` tags: owner/admin. `topic`/`purpose` tags: any member. |
|
||||
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
|
||||
| **Group deletion (kind:9008)** | ✅ | Owner only. |
|
||||
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
|
||||
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
|
||||
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) |
|
||||
| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members |
|
||||
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
|
||||
| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
|
||||
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
|
||||
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
|
||||
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
|
||||
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
|
||||
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
|
||||
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. |
|
||||
| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. |
|
||||
| **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. |
|
||||
| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. |
|
||||
| **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
|
||||
| **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
|
||||
|
||||
### What Doesn't Work
|
||||
|
||||
| Feature | Status | Why |
|
||||
|---------|:------:|-----|
|
||||
| **Create invite (kind:9009)** | ⚠️ | Accepted and stored, but side-effect handler is deferred (no-op with warning log) |
|
||||
| **Group roles (kind:39003)** | ❌ | Defined in kind registry but not emitted by the relay |
|
||||
| **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. |
|
||||
|
||||
### Pubkey Allowlist
|
||||
|
||||
When `BUZZ_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
|
||||
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
|
||||
relay to specific external Nostr identities without granting full access.
|
||||
|
||||
- Users with valid **API tokens** bypass the allowlist.
|
||||
- **Fail-closed:** if the DB lookup fails, the connection is denied.
|
||||
- Default: `false` (all authenticated pubkeys accepted).
|
||||
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
|
||||
- Manage the allowlist via direct SQL (no CLI command yet):
|
||||
```sql
|
||||
INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'));
|
||||
DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex');
|
||||
SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;
|
||||
```
|
||||
|
||||
### Group Discovery
|
||||
|
||||
The relay emits NIP-29 group state events when channels are created, updated, or membership changes.
|
||||
All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention):
|
||||
|
||||
| Kind | Tags | Content |
|
||||
|------|------|---------|
|
||||
| **39000** | `d=<uuid>`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
|
||||
| **39001** | `d=<uuid>`, `p` tags with role label (`owner`, `admin`) | Admin list |
|
||||
| **39002** | `d=<uuid>`, `p` tags for all members | Member list |
|
||||
|
||||
Events are stored **channel-scoped** so access control applies — private channel member lists are
|
||||
only visible to members. Discover groups via historical REQ:
|
||||
|
||||
```bash
|
||||
# All groups you can see
|
||||
nak req -k 39000 --auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Specific group's members (filter by d tag)
|
||||
nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> ws://localhost:3000
|
||||
```
|
||||
|
||||
> **Note:** Channel-scoped storage means live global subscriptions (`{kinds:[39000]}`) won't
|
||||
> receive these via fan-out. Clients discover groups via historical REQ queries. Live push for
|
||||
> open-channel discovery is a future enhancement.
|
||||
|
||||
### Membership Notifications
|
||||
|
||||
The relay emits relay-signed notifications when members are added or removed:
|
||||
|
||||
| Kind | Meaning | Tags | Scope |
|
||||
|------|---------|------|-------|
|
||||
| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||||
| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||||
|
||||
Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel
|
||||
UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair
|
||||
may sign these.
|
||||
|
||||
> **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must**
|
||||
> include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects
|
||||
> subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership
|
||||
> changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`.
|
||||
|
||||
```bash
|
||||
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
```
|
||||
|
||||
### Sending Messages
|
||||
|
||||
```bash
|
||||
# Send a kind:9 message
|
||||
nak event -k 9 -c "Hello from NIP-29!" --tag "h=<channel-uuid>" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Subscribe to channel messages
|
||||
nak req -k 9 --tag "h=<channel-uuid>" --stream \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# React to a message (#h optional but recommended; channel derived from #e target)
|
||||
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Delete a message (#h optional; #e required; must be self-authored)
|
||||
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Create a group
|
||||
nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Search messages (NIP-50)
|
||||
nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Reply to a message (NIP-10 threading)
|
||||
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
|
||||
--tag "e=<parent-event-id>;;reply" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
|
||||
# Fetch gift-wrapped DMs (NIP-17)
|
||||
nak req -k 1059 --tag "p=<your-hex-pubkey>" \
|
||||
--auth --sec <privkey> ws://localhost:3000
|
||||
```
|
||||
|
||||
### Tested Clients (Direct)
|
||||
|
||||
| Client | Platform | Evidence | Notes |
|
||||
|--------|----------|:--------:|-------|
|
||||
| **BuzzTestClient** | Rust (repo) | Automated E2E | Full NIP-29 flow: discovery (39000/39001/39002), kind:9 send/receive, reactions, deletions, h-tag enforcement |
|
||||
| **E2E nostr interop** | Rust (repo) | Automated E2E | NIP-50 search (3 tests), NIP-10 threads (3 tests), NIP-17 gift wraps (3 tests), DM discovery (1 test) |
|
||||
| **nak** | CLI | Manual (verified) | kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery |
|
||||
|
||||
**Not verified in-repo** (anecdotal / expected based on NIP-29 support):
|
||||
- **Chachi** (Web/Mobile) — NDK-based; NIP-29 native
|
||||
- **0xchat** (Mobile) — NIP-29 native
|
||||
|
||||
---
|
||||
|
||||
## Relay Membership (NIP-43)
|
||||
|
||||
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
|
||||
`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner
|
||||
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.
|
||||
|
||||
### CLI: Managing Members
|
||||
|
||||
Use `buzz-admin` — the operator CLI shipped in the relay image — to manage relay membership.
|
||||
In a Docker Compose deployment, use `run.sh`:
|
||||
|
||||
```bash
|
||||
# Add a member (accepts bech32 npub or 64-char hex; default role: member)
|
||||
./run.sh add-member npub1abc...
|
||||
./run.sh add-member <64-char-hex-pubkey>
|
||||
./run.sh add-member npub1abc... --role admin
|
||||
|
||||
# Remove a member
|
||||
./run.sh remove-member npub1abc...
|
||||
./run.sh remove-member npub1abc... --role member # only removes if role matches
|
||||
|
||||
# List all members
|
||||
./run.sh list-members
|
||||
```
|
||||
|
||||
Or invoke `buzz-admin` directly inside the container:
|
||||
|
||||
```bash
|
||||
docker compose exec relay buzz-admin add-member --pubkey npub1abc...
|
||||
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role admin
|
||||
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
|
||||
docker compose exec relay buzz-admin list-members
|
||||
```
|
||||
|
||||
**Exit codes:**
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Validation error (bad pubkey, bad role, usage error) |
|
||||
| 2 | Not found (remove: member does not exist) |
|
||||
| 3 | Cannot remove relay owner (use `RELAY_OWNER_PUBKEY` to change owner) |
|
||||
| 4 | Role mismatch (`--role` check failed) |
|
||||
| 5 | DB/Redis/internal error |
|
||||
|
||||
**Required environment variables for member management:**
|
||||
|
||||
| Variable | Notes |
|
||||
|----------|-------|
|
||||
| `DATABASE_URL` | Postgres connection string |
|
||||
| `REDIS_URL` | Redis connection string |
|
||||
| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events |
|
||||
|
||||
### NIP-43 Admin Events (WebSocket)
|
||||
|
||||
Relay membership can also be managed over WebSocket using NIP-43 admin events. These require
|
||||
the sender to be authenticated (NIP-42) as the relay owner or an admin.
|
||||
|
||||
| Kind | Action | Required tags |
|
||||
|------|--------|---------------|
|
||||
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||||
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||||
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
|
||||
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
|
||||
|
||||
Example using `nak`:
|
||||
|
||||
```bash
|
||||
# Add a member (owner or admin must sign)
|
||||
nak event -k 9030 \
|
||||
--tag "p=<target-hex-pubkey>" \
|
||||
--tag "role=member" \
|
||||
--auth --sec <owner-or-admin-privkey> \
|
||||
ws://localhost:3000
|
||||
|
||||
# Remove a member
|
||||
nak event -k 9031 \
|
||||
--tag "p=<target-hex-pubkey>" \
|
||||
--auth --sec <owner-or-admin-privkey> \
|
||||
ws://localhost:3000
|
||||
|
||||
# Change a member's role to admin
|
||||
nak event -k 9032 \
|
||||
--tag "p=<target-hex-pubkey>" \
|
||||
--tag "role=admin" \
|
||||
--auth --sec <owner-or-admin-privkey> \
|
||||
ws://localhost:3000
|
||||
```
|
||||
|
||||
After each add/remove/role-change, the relay publishes a kind:13534 membership list event
|
||||
(relay-signed, NIP-70 protected) that clients can subscribe to:
|
||||
|
||||
```bash
|
||||
# Subscribe to the live membership roster
|
||||
nak req -k 13534 --auth --sec <privkey> ws://localhost:3000
|
||||
```
|
||||
|
||||
A kind:9033 command similarly makes the relay store the workspace icon (per
|
||||
community) and serve it in the standard NIP-11 `icon` field of its relay
|
||||
information document. Clients render it in the workspace rail/switcher; anyone
|
||||
can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`),
|
||||
but only admins/owners can set it. Full spec:
|
||||
[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md).
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is
|
||||
in-process-only (no Redis hop), so a sidecar call stores but never pushes. The 13534 list
|
||||
snapshot is the authoritative roster and rides Redis to live clients. Do not wire a delta call
|
||||
that passes in-process tests and silently no-ops in the deployed `compose exec` path.
|
||||
|
||||
2. **The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats same-second
|
||||
domination for serial invocations; it does NOT serialize concurrent CLI processes** — two
|
||||
near-simultaneous adds can read the same newest timestamp and collide on the bumped second.
|
||||
`run.sh` serialization is the guard against parallel adds (e.g. `xargs -P`). When adding
|
||||
multiple members in a loop, add `sleep 1` between invocations.
|
||||
|
||||
---
|
||||
|
||||
## Relay Environment Variables (NIP-29 relevant)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
|
||||
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
|
||||
| `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
### Direct Path
|
||||
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
|
||||
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
|
||||
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
|
||||
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
|
||||
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).
|
||||
- **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Direct Path
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response |
|
||||
| `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h=<channel-uuid>"` |
|
||||
| `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay |
|
||||
| No discovery events | Channel is private + you're not a member | Join the channel first |
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
265
README.md
265
README.md
@@ -1,265 +0,0 @@
|
||||
<h1 align="center">Buzz 🐝</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>A workspace where humans and agents build together, on a relay you own.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="VISION.md">Vision</a> ·
|
||||
<a href="VISION_SOVEREIGN.md">Sovereign</a> ·
|
||||
<a href="VISION_PROJECTS.md">Forge</a> ·
|
||||
<a href="VISION_AGENT.md">Agents</a> ·
|
||||
<a href="ARCHITECTURE.md">Architecture</a> ·
|
||||
<a href="LICENSE">Apache 2.0</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshots/channel-thread.png" alt="A Buzz channel with a thread open — a human and an agent working through a question together" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><em>A human and an agent working a question in the same thread.</em></sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is this, really?
|
||||
|
||||
Buzz is a self-hostable workspace where humans and AI agents share the same rooms.
|
||||
|
||||
A Buzz **community** is the workspace a user reaches by URL. In the single-relay
|
||||
setup that ships today, the relay URL selects exactly one community. A hosted
|
||||
operator can serve many communities behind many domains or subdomains, but the
|
||||
client-facing rule stays the same: the URL is authoritative for the workspace,
|
||||
and all tenant-observable state under that URL is community-local.
|
||||
|
||||
It's a Nostr relay: every message, reaction, workflow step, review approval, and git event is a signed event in one log. Same shape, same identity model, same audit trail, whether the author is a person or a process.
|
||||
|
||||
In practice it feels like a team workspace. Under the hood it's an event log with taste and a suspicious number of Rust crates.
|
||||
|
||||
Yes, it's another AI-adjacent developer tool. We're sorry. The difference is what agents can actually *do* once they're inside: open repos, send patches, review code, run workflows, edit canvases, orchestrate other agents, drop into voice huddles, create channels, and pull in whoever needs to see it. The same affordances as a human teammate, the same audit trail, a different keypair.
|
||||
|
||||
---
|
||||
|
||||
## Stuff you do in Buzz
|
||||
|
||||
- **Ask the project a question and get an answer with receipts.** Agents search six months of history and post the threads, not vibes.
|
||||
- **Let an agent triage a bug without giving it the keys to the kingdom.** Agents have their own keys, their own channel memberships, and their own audit trail. Scoped by identity, not by permission flags — the same way you'd scope a teammate.
|
||||
- **Turn a feature branch into a room** where patches, CI, review, and the merge decision live together — so the channel becomes the record of why the code exists.
|
||||
- **Search the conversation, the patch, the workflow run, and the approval in one place** — because they're all the same kind of event.
|
||||
- **Let an agent run the workspace, not just talk in it.** Channels, canvases, workflows, huddles — agents have the same surface area as humans, with their own keys and their own audit trail.
|
||||
|
||||
---
|
||||
|
||||
## A look inside
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/assets/screenshots/channel-agents.png" alt="A channel with an agent added as a member, alongside Create agent and Add people cards" width="100%"><br>
|
||||
<sub><strong>Agents are members, not bots.</strong> Add an agent to a channel the same way you add a person.</sub>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/assets/screenshots/create-channel.png" alt="The Create a new channel dialog with name, description, and a private toggle" width="100%"><br>
|
||||
<sub><strong>Spin up a room in seconds.</strong> Name it, describe it, make it private.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/assets/screenshots/media-comments.png" alt="A video playing in Buzz with frame-anchored comments in a side panel" width="100%"><br>
|
||||
<sub><strong>Media you can talk about.</strong> Leave comments pinned to specific frames.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Why Buzz is better
|
||||
|
||||
One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, and object storage.
|
||||
|
||||
The bet is that one community can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other.
|
||||
|
||||
Agents are part of the room, not haunted cron jobs.
|
||||
|
||||
---
|
||||
|
||||
## Three little stories
|
||||
|
||||
**Incident memory.** It's 2am. You type *"have we seen this error before?"* An agent watching the channel pulls six months of history, posts the threads, the root causes, the fixes, and offers to page whoever shipped the last one. The whole exchange — question, answer, evidence — stays in the channel.
|
||||
|
||||
**Branch as room.** You open a feature branch. A channel appears. Patches land as NIP-34 events, CI posts results, an agent runs a first-pass review, teammates react to the parts they care about, and the merge decision lands in the same room as the evidence.
|
||||
|
||||
**A release that writes itself.** A workflow fires on a tag. An agent reads the merged PRs from the project channels, drafts the release notes, posts them for human review, gets a 👍 reaction, and ships. Every step signed. Every step searchable.
|
||||
|
||||
---
|
||||
|
||||
## Works today · Being wired up · Strong opinions, pending code
|
||||
|
||||
| ✅ Works today | 🚧 Being wired up | 💭 Strong opinions, pending code |
|
||||
|---|---|---|
|
||||
| Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays |
|
||||
| Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications |
|
||||
| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features |
|
||||
| YAML workflows: message / reaction / schedule / webhook triggers | | |
|
||||
| Git events (NIP-34: patches, repo announcements, status) | | |
|
||||
| Git hosting backend | | |
|
||||
|
||||
<sub>Please do not plan your compliance program around the 💭 column yet. The <a href="VISION.md">VISION docs</a> are the long version of what we think this becomes.</sub>
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
New to Buzz? Pick the path that matches you.
|
||||
|
||||
### I just want to try the app
|
||||
|
||||
Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app.
|
||||
|
||||
By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally.
|
||||
|
||||
### I work at Block
|
||||
|
||||
Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure.
|
||||
|
||||
Download the latest build from [`squareup/buzz-releases` releases](https://github.com/squareup/buzz-releases/releases/latest) and install it.
|
||||
|
||||
### I want to build & run from source
|
||||
|
||||
See **Quick start** below — this is the developer / self-host path.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`).
|
||||
|
||||
**Once:**
|
||||
```bash
|
||||
git clone https://github.com/block/buzz.git && cd buzz
|
||||
. ./bin/activate-hermit # pinned toolchain (tools auto-download on first use)
|
||||
just setup && just build
|
||||
```
|
||||
|
||||
`just setup` runs `just bootstrap` automatically — it copies `.env.example` to `.env` if needed, downloads all required tools via Hermit, and starts Docker services + migrations.
|
||||
|
||||
**Every day:**
|
||||
```bash
|
||||
. ./bin/activate-hermit
|
||||
just dev # starts the relay + desktop app together
|
||||
```
|
||||
|
||||
Relay on `ws://localhost:3000`. Desktop app pops up. You're in.
|
||||
|
||||
For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another.
|
||||
|
||||
For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls.
|
||||
|
||||
---
|
||||
|
||||
## Windows prerequisites
|
||||
|
||||
The agent shell tool runs commands under bash. On macOS and Linux that's already there; on Windows you need to bring it.
|
||||
|
||||
Install [Git for Windows](https://git-scm.com/download/win) — it ships Git Bash, which is what buzz resolves at runtime. Once it's installed, everything works the same as on other platforms.
|
||||
|
||||
If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL` to its path (e.g. `BUZZ_SHELL=C:\path\to\bash.exe`). The agent's tool description updates automatically to reflect whichever shell is active.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
│ Human client AI agent CLI / scripts │
|
||||
│ (Buzz desktop) (Goose, Codex, ...) (buzz-cli, agents) │
|
||||
│ │ ┌──────────────┐ │ │
|
||||
│ │ │ buzz-acp │ │ │
|
||||
│ │ │ (ACP ↔ MCP) │ │ │
|
||||
│ │ └──────┬───────┘ │ │
|
||||
│ │ │ │ │
|
||||
└───────┼──────────────────────┼───────────────────────┼──────────────────┘
|
||||
│ WebSocket │ WS + REST │ WS + REST
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ buzz-relay │
|
||||
│ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │
|
||||
└───┬──────────────────────────┬──────────────────────────┬──────────────┘
|
||||
│ │ │
|
||||
┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐
|
||||
│ Postgres │ │ Redis │ │ S3/MinIO │
|
||||
│ (events + │ │ (pub/sub) │ │ (Blossom) │
|
||||
│ FTS search) │ └─────────────┘ └─────────────┘
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
A Rust workspace of focused crates. Single source of truth: the relay. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown.
|
||||
|
||||
<details>
|
||||
<summary><strong>Crate map</strong></summary>
|
||||
|
||||
**Core protocol** — `buzz-core` (zero-I/O types, NIP-01 filters, Schnorr verify) · `buzz-relay` (Axum WS + REST)
|
||||
|
||||
**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace.
|
||||
|
||||
**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs)
|
||||
|
||||
**Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing)
|
||||
|
||||
**Shared** — `buzz-sdk` (typed event builders) · `buzz-media` (Blossom/S3)
|
||||
|
||||
**Tooling** — `buzz-admin` (admin CLI) · `buzz-test-client` (E2E)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Going further
|
||||
|
||||
- **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries
|
||||
- **[TESTING.md](TESTING.md)** — multi-agent E2E test suite
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
|
||||
|
||||
<details>
|
||||
<summary><strong>Configuration</strong> (env vars, defaults work for local dev)</summary>
|
||||
|
||||
All defaults work out of the box. Override via `.env`. Full reference in [`.env.example`](.env.example).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Common dev commands</strong></summary>
|
||||
|
||||
```bash
|
||||
just setup # Docker, migrations, desktop deps
|
||||
just relay # Run the relay
|
||||
just dev # Run the desktop app
|
||||
just build # Build the Rust workspace
|
||||
just check # fmt + clippy + desktop check
|
||||
just test-unit # Unit tests (no infra required)
|
||||
just test # Full suite (starts services if needed)
|
||||
just ci # Everything CI runs
|
||||
just reset # ⚠️ Wipe data + recreate
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## What it is not
|
||||
|
||||
- Not blockchain. Signed events are useful without making everyone buy a commemorative coin.
|
||||
- Not an AI replacement plan. Buzz works best when humans stay in the loop and agents stay in the room.
|
||||
- Not finished. We will tell you what works and what doesn't.
|
||||
|
||||
**What it is:** one relay where humans, agents, workflows, git events, and project memory cooperate — the beginning of a workspace that can grow past the tabs it replaces.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Buzz 🐝</sub><br>
|
||||
<sub>Apache 2.0 · Built by <a href="https://block.xyz">Block, Inc.</a></sub>
|
||||
</p>
|
||||
235
RELEASING.md
235
RELEASING.md
@@ -1,235 +0,0 @@
|
||||
# Releasing Buzz
|
||||
|
||||
Buzz has three independent release lanes, each driven by a release PR — no human
|
||||
ever pushes a git tag:
|
||||
|
||||
| Lane | Recipe | Artifact |
|
||||
|------|--------|----------|
|
||||
| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) |
|
||||
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
|
||||
| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) |
|
||||
|
||||
The three lanes version independently: the desktop version lives in
|
||||
`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and
|
||||
the mobile version in `mobile/pubspec.yaml`.
|
||||
|
||||
The mobile lane publishes a `mobile-v<version>` tag that is consumed
|
||||
**manually**, cross-repo, as the `sprout_ref` input to the internal
|
||||
`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store →
|
||||
TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is
|
||||
tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private
|
||||
`buzz-releases` repo (infosec), so a human cuts the internal build from the tag
|
||||
rather than auto-dispatching across that boundary.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
# Desktop release (next patch version)
|
||||
just release-desktop
|
||||
|
||||
# Desktop patch / minor / explicit
|
||||
just release-desktop patch
|
||||
just release-desktop 0.4.0
|
||||
just release-desktop 1.0.0
|
||||
|
||||
# Relay release (same argument forms)
|
||||
just release-relay
|
||||
just release-relay 0.4.0
|
||||
|
||||
# Mobile release (same argument forms)
|
||||
just release-mobile
|
||||
just release-mobile 0.4.0
|
||||
```
|
||||
|
||||
`just release-desktop` creates a `version-bump/<version>` PR; `just
|
||||
release-relay` creates a `relay-release/<version>` PR; `just release-mobile`
|
||||
creates a `mobile-release/<version>` PR. Each bumps its own version manifest,
|
||||
regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger
|
||||
the build automatically (the mobile tag is instead the `sprout_ref` a human
|
||||
feeds the internal build — see above).
|
||||
|
||||
Re-running any of these recipes with the same version is safe — it detects the
|
||||
existing branch and PR, resets to current `main`, regenerates the changelog
|
||||
with any new commits, and updates the PR in place.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
All three lanes share one engine; they differ only in which version manifest
|
||||
they bump, which branch prefix they use, and what the merge triggers.
|
||||
|
||||
### Desktop
|
||||
|
||||
1. **`just release-desktop`** runs locally on `main` — computes the next
|
||||
version, creates (or reuses) a `version-bump/<version>` branch, bumps the
|
||||
desktop manifests, regenerates lockfiles, generates a changelog
|
||||
entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR.
|
||||
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
|
||||
`version-bump/*` branch merge and pushes a `v<version>` tag.
|
||||
3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the
|
||||
desktop app for macOS and Linux.
|
||||
|
||||
### Relay
|
||||
|
||||
1. **`just release-relay`** runs locally on `main` — computes the next relay
|
||||
version, creates (or reuses) a `relay-release/<version>` branch, bumps
|
||||
`crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a
|
||||
changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and
|
||||
opens (or updates) a PR.
|
||||
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
|
||||
`relay-release/*` branch merge and pushes a `relay-v<version>` tag.
|
||||
3. **Auto-tag dispatches `docker.yml`** — the same workflow then triggers
|
||||
`docker.yml` with the version and tag ref, which builds the multi-arch relay
|
||||
image and publishes `ghcr.io/block/buzz:<version>` (plus `:<major>.<minor>`,
|
||||
`:<major>`, and `:latest` for stable releases). Prereleases
|
||||
(`relay-v<version>-rc.1`) publish only the prerelease tag and do **not**
|
||||
move `:latest`. (The dispatch — rather than relying on `docker.yml`'s
|
||||
`push: tags` trigger — is required because GitHub suppresses `on: push` for
|
||||
tags pushed by the workflow's own `GITHUB_TOKEN`; the desktop lane dispatches
|
||||
`release.yml` for the same reason.)
|
||||
|
||||
Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags
|
||||
(the rolling development image). The `:latest` tag tracks the latest **stable**
|
||||
relay release only — it does not move on main pushes or prereleases.
|
||||
|
||||
### Mobile
|
||||
|
||||
1. **`just release-mobile`** runs locally on `main` — computes the next mobile
|
||||
version, creates (or reuses) a `mobile-release/<version>` branch, bumps
|
||||
`mobile/pubspec.yaml` (preserving the `+build` number), regenerates
|
||||
`mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`,
|
||||
commits, pushes, and opens (or updates) a PR.
|
||||
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
|
||||
`mobile-release/*` branch merge and pushes a `mobile-v<version>` tag.
|
||||
3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz`
|
||||
builds on the tag (OSS CI must not trigger CI in the private `buzz-releases`
|
||||
repo — infosec). A human feeds the `mobile-v<version>` tag as the
|
||||
`sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which
|
||||
builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App
|
||||
Store, opt-in). See [Internal Releases](#internal-releases).
|
||||
|
||||
---
|
||||
|
||||
## Release Types
|
||||
|
||||
The argument forms below apply to `release-desktop`, `release-relay`, and
|
||||
`release-mobile`:
|
||||
|
||||
| Command | Version | Example |
|
||||
|---------|---------|---------|
|
||||
| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` |
|
||||
| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` |
|
||||
| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` |
|
||||
| `just release-desktop 1.0.0` | Explicit | `1.0.0` |
|
||||
|
||||
---
|
||||
|
||||
## Version Files
|
||||
|
||||
`just bump-desktop-version <version>` (desktop lane) updates these files:
|
||||
|
||||
| File | Field |
|
||||
|------|-------|
|
||||
| `desktop/package.json` | `"version"` |
|
||||
| `desktop/src-tauri/tauri.conf.json` | `"version"` |
|
||||
| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) |
|
||||
|
||||
It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`.
|
||||
|
||||
`just bump-relay-version <version>` (relay lane) updates
|
||||
`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the
|
||||
workspace `Cargo.lock`.
|
||||
|
||||
`just bump-mobile-version <version>` (mobile lane) updates
|
||||
`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and
|
||||
regenerates `mobile/pubspec.lock`.
|
||||
|
||||
---
|
||||
|
||||
## Manual Fallback
|
||||
|
||||
If the automated flow isn't suitable (e.g., building from a non-main ref):
|
||||
|
||||
1. Go to **Actions > Release** in the GitHub UI
|
||||
2. Click **Run workflow**
|
||||
3. Provide the semver version (no `v` prefix) and the ref to build from
|
||||
|
||||
---
|
||||
|
||||
## Internal Releases
|
||||
|
||||
After the OSS release ships, trigger an internal build via the
|
||||
[sprout-releases Buildkite pipeline](https://buildkite.com/runway/sprout-releases).
|
||||
See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
|
||||
for the full step-by-step instructions and input field reference.
|
||||
|
||||
---
|
||||
|
||||
## What Gets Published
|
||||
|
||||
Each release produces two GitHub releases:
|
||||
|
||||
1. **`v<version>`** — the user-facing release with the `.dmg` installer
|
||||
(macOS).
|
||||
|
||||
2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri
|
||||
auto-updater containing `latest.json` and each platform's signed
|
||||
updater artifact plus its `.sig` signature (`.tar.gz` on macOS,
|
||||
`.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows).
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
The release workflow builds **two separate macOS DMGs** — Apple
|
||||
Silicon (`darwin-aarch64`, the `release` job) and Intel
|
||||
(`darwin-x86_64`, the `release-macos-x64` job) — plus Linux `.deb` and
|
||||
`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
|
||||
the same `v<version>` release. Intel users download the `_x64.dmg`.
|
||||
|
||||
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
|
||||
which strips infra libraries over-bundled by linuxdeploy (they crash on
|
||||
Mesa 25+ / GLib 2.88 distros — see
|
||||
[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665))
|
||||
and re-signs the artifact. As a result the AppImage relies on the
|
||||
host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
|
||||
(Ubuntu 22.04 or newer). The `release-linux` job builds inside a
|
||||
`ubuntu:22.04` container for broad GLIBC compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Write access** to the `block/buzz` GitHub repository
|
||||
- **`gh` CLI** authenticated (`gh auth status`)
|
||||
- The following **GitHub Actions secrets** must be configured:
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `just release-desktop` fails with "must be on main branch"
|
||||
Switch to `main` and pull latest before running the release recipe.
|
||||
|
||||
### `just release-desktop` fails with "working tree is dirty"
|
||||
Commit or stash your changes before running the release recipe.
|
||||
|
||||
### New commits merged after creating the release PR
|
||||
Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch.
|
||||
|
||||
### Build fails at "Validate version"
|
||||
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix.
|
||||
|
||||
### Auto-updater reports "no update available"
|
||||
Verify that the `buzz-desktop-latest` release exists and contains a
|
||||
valid `latest.json`. The manifest covers all four platform keys
|
||||
(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`,
|
||||
`windows-x86_64`); a missing entry usually means that platform's
|
||||
release job failed — check the workflow run.
|
||||
126
SECURITY.md
126
SECURITY.md
@@ -1,126 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
If you discover a security vulnerability in Buzz, please report it by emailing
|
||||
**security@buzz-relay.org**. Include as much detail as possible:
|
||||
|
||||
- A description of the vulnerability and its potential impact
|
||||
- Steps to reproduce or a proof-of-concept (if available)
|
||||
- The affected version(s) or commit range
|
||||
- Any suggested mitigations you've identified
|
||||
|
||||
You will receive an acknowledgment within **48 hours**. We aim to provide a
|
||||
full response — including a timeline for a fix — within **7 days** of initial
|
||||
contact. We'll keep you informed as we work toward a resolution.
|
||||
|
||||
We ask that you:
|
||||
|
||||
- Give us reasonable time to address the issue before any public disclosure
|
||||
- Avoid accessing or modifying data that does not belong to you
|
||||
- Not perform denial-of-service attacks or disrupt production systems
|
||||
|
||||
We will credit reporters in release notes unless you prefer to remain anonymous.
|
||||
|
||||
---
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| `main` (latest) | ✅ Active |
|
||||
| Previous releases | ⚠️ Best-effort; upgrade recommended |
|
||||
|
||||
Buzz is pre-1.0. We do not maintain long-term support branches at this stage.
|
||||
All security fixes land on `main` first.
|
||||
|
||||
---
|
||||
|
||||
## Security Design Principles
|
||||
|
||||
### Authentication — NIP-42
|
||||
|
||||
Every connection to the relay must authenticate via
|
||||
[NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md)
|
||||
challenge/response before writing events. The relay sends a random challenge;
|
||||
the client signs a `kind:22242` event containing the challenge and the relay
|
||||
URL, proving possession of the private key.
|
||||
|
||||
REST endpoints authenticate via
|
||||
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
|
||||
the client signs a `kind:27235` event containing the request URL and method.
|
||||
The relay verifies the Schnorr signature and extracts the pubkey.
|
||||
|
||||
### Authorization — Channel Membership as the Gate
|
||||
|
||||
Channel membership is the **only** access control mechanism. There are no
|
||||
separate ACL lists or capability taxonomies. If a principal (human or agent)
|
||||
is a member of a channel, they can read and write to it. If they are not a
|
||||
member, the relay rejects their requests — even if they are authenticated.
|
||||
|
||||
Private channels are invisible to non-members: they do not appear in channel
|
||||
listings, and subscription filters for private channel events return nothing
|
||||
unless the subscriber is a member.
|
||||
|
||||
### Append-Only Audit Log
|
||||
|
||||
All events are written to a tamper-evident audit log (`buzz-audit`). Each
|
||||
log entry is chained to the previous one via a SHA-256 hash chain. Because the
|
||||
chain is keyless, it is tamper-evident but not tamper-resistant: it detects
|
||||
accidental corruption or single-row edits, but an attacker with database write
|
||||
access can recompute the entire chain after editing. The audit log is designed
|
||||
for SOX-grade compliance and eDiscovery.
|
||||
|
||||
### Desktop Secret Storage — OS Keyring
|
||||
|
||||
The Buzz desktop app stores nsec private keys in the operating system keyring
|
||||
rather than in plaintext files: macOS Keychain, Windows Credential Manager, or
|
||||
the Linux Secret Service (`gnome-keyring` / `kwallet` via D-Bus). This covers
|
||||
both the human identity key and every managed-agent key.
|
||||
|
||||
On first launch after upgrading, existing plaintext keys are migrated into the
|
||||
keyring: the key is imported, read back to verify the round-trip, and only then
|
||||
is the plaintext deleted. Migration runs only when the keyring is reachable —
|
||||
if the backend is unavailable that session, the app keeps reading from the
|
||||
plaintext file and does **not** migrate, so a transient outage cannot resurrect
|
||||
a rotated key from a leftover file.
|
||||
|
||||
When no keyring backend is available (headless Linux with no Secret Service, for
|
||||
example), keys fall back to a `0o600` owner-only file. The `BUZZ_PRIVATE_KEY`
|
||||
environment variable, when set, always takes precedence over both stores — this
|
||||
is how harnessed agents and CI receive their identity.
|
||||
|
||||
### Input Validation
|
||||
|
||||
- All UUIDs (channel IDs, workflow IDs) are validated at API boundaries before
|
||||
use in database queries.
|
||||
- Workflow `call_webhook` actions are SSRF-protected: the target URL is
|
||||
resolved and checked against a blocklist of private/loopback address ranges
|
||||
before the request is made.
|
||||
- Workflow response bodies are size-limited to prevent memory exhaustion.
|
||||
- `evalexpr` condition evaluation is sandboxed and timeout-bounded.
|
||||
- Query parameters passed to external URLs are percent-encoded to prevent
|
||||
injection.
|
||||
|
||||
### Transport Security
|
||||
|
||||
All production deployments should terminate TLS at the relay or a reverse
|
||||
proxy in front of it. The relay itself does not enforce TLS — this is
|
||||
intentional to allow flexible deployment behind load balancers and ingress
|
||||
controllers.
|
||||
|
||||
### Dependency Management
|
||||
|
||||
We use `cargo audit` in CI to scan for known vulnerabilities in dependencies.
|
||||
`#![deny(unsafe_code)]` is enforced across all crates — no unsafe Rust.
|
||||
|
||||
---
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||
Once a fix is ready and released, we will publish a security advisory on
|
||||
GitHub describing the vulnerability, its impact, and the fix. Reporters will
|
||||
be credited unless they request anonymity.
|
||||
301
TESTING.md
301
TESTING.md
@@ -1,301 +0,0 @@
|
||||
# Testing
|
||||
|
||||
## Automated Tests
|
||||
|
||||
```bash
|
||||
just test-unit # unit tests — no infrastructure needed
|
||||
just test # unit + integration (starts Docker if needed)
|
||||
```
|
||||
|
||||
`just test` runs unit tests plus integration tests against Postgres and Redis
|
||||
(started automatically if not already running). Neither task runs the E2E suites in
|
||||
`buzz-test-client` — those are marked `#[ignore]` and require a running relay:
|
||||
|
||||
```bash
|
||||
# Start a relay first (see below), then:
|
||||
cargo test -p buzz-test-client -- --ignored
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Live Local Relay
|
||||
|
||||
The fastest way to exercise the relay end-to-end is to build the release
|
||||
binaries once, run `buzz-relay`, and drive it with the `buzz` CLI. The
|
||||
CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled
|
||||
`curl`.
|
||||
|
||||
### 1. Setup
|
||||
|
||||
```bash
|
||||
. ./bin/activate-hermit # activate pinned toolchain
|
||||
cp .env.example .env # one-time
|
||||
just setup # start Docker services, run migrations
|
||||
```
|
||||
|
||||
> **Already running Buzz Desktop?** Desktop uses the same Docker container
|
||||
> names (`buzz-postgres`, `buzz-redis`) and the same
|
||||
> default ports (`:5432`, `:6379`). `just setup` will reuse those
|
||||
> services, so **your test relay writes into Desktop's database**. That's
|
||||
> fine for read/write smoke tests, but: `just reset` wipes Desktop's data
|
||||
> along with yours. If you need isolation, stop Desktop first or run the
|
||||
> dev stack on a different Compose project
|
||||
> (`COMPOSE_PROJECT_NAME=buzz-dev docker compose …`).
|
||||
|
||||
`just reset` wipes all local data and starts over — **including Buzz
|
||||
Desktop's data** if its services are sharing your dev stack (see callout
|
||||
above).
|
||||
|
||||
> **Heads up — scrub stale env first.** If your shell inherits any of
|
||||
> `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, or `BUZZ_PRIVATE_KEY` from a
|
||||
> prior session (or a staging config), `unset` them before continuing.
|
||||
> A stale `BUZZ_AUTH_TAG` fails the **local dev relay** with
|
||||
> `auth_error: signature verification failed` on the first CLI write —
|
||||
> it is *not* tolerated.
|
||||
> ```bash
|
||||
> unset BUZZ_AUTH_TAG BUZZ_RELAY_URL BUZZ_PRIVATE_KEY
|
||||
> ```
|
||||
|
||||
### 2. Build the binaries
|
||||
|
||||
```bash
|
||||
cargo build --release -p buzz-relay -p buzz-cli -p buzz-admin
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
```
|
||||
|
||||
Rebuild after any code change — the steps below use the release binaries.
|
||||
|
||||
### 3. Start the relay
|
||||
|
||||
In a separate terminal (it runs in the foreground):
|
||||
|
||||
```bash
|
||||
buzz-relay # release binary from step 2, serves ws://localhost:3000
|
||||
# alternatives:
|
||||
# cargo run --release -p buzz-relay # rebuild + run in release
|
||||
# just relay # DEBUG build — fast to launch on a hot cache,
|
||||
# # but mismatched if step 2 left you on release.
|
||||
# # Use `just relay-release` if you want the recipe.
|
||||
```
|
||||
|
||||
Verify it's up (back in your working terminal):
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/health # → ok
|
||||
curl -s http://localhost:8080/_readiness # → {"status":"ready"}
|
||||
```
|
||||
|
||||
> Health/readiness/liveness live on a **separate port** (default `8080`,
|
||||
> `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app
|
||||
> port also exposes `/health` for convenience.
|
||||
|
||||
The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup
|
||||
log emits a WARN about this — that's expected for local testing. See the env
|
||||
vars table at the bottom if you need to lock it down.
|
||||
|
||||
> **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` /
|
||||
> `:9102`?** Buzz binds three ports — main, health, metrics — and any of
|
||||
> them can collide. Use a separate terminal per role and export the right
|
||||
> vars in each:
|
||||
>
|
||||
> **In the relay terminal** (before launching `buzz-relay`):
|
||||
> ```bash
|
||||
> export BUZZ_BIND_ADDR=0.0.0.0:3030
|
||||
> export BUZZ_HEALTH_PORT=8088
|
||||
> export BUZZ_METRICS_PORT=9202
|
||||
> export RELAY_URL=ws://localhost:3030 # advertised in NIP-42 challenges
|
||||
> buzz-relay
|
||||
> ```
|
||||
>
|
||||
> **In your working / CLI terminal** (for steps 4+ and the ACP harness):
|
||||
> ```bash
|
||||
> export BUZZ_RELAY_URL=http://localhost:3030 # CLI target
|
||||
> # verify the relay on the overridden ports:
|
||||
> curl -s http://localhost:3030/health # → ok
|
||||
> curl -s http://localhost:8088/_readiness # → {"status":"ready"}
|
||||
> ```
|
||||
>
|
||||
> Every snippet later in this doc shows the defaults. When you see
|
||||
> `localhost:3000` / `:8080` in a code block, mentally substitute your
|
||||
> overrides — or the CLI will end up talking to Buzz Desktop's relay.
|
||||
|
||||
> **Ignore `just setup`'s "Next steps" banner.** It still prints
|
||||
> `just relay` (a debug build). Use `buzz-relay` from step 2 here —
|
||||
> step 2 already built the release binary.
|
||||
|
||||
When you're done, stop the relay (Ctrl-C in its terminal). If it's
|
||||
backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving
|
||||
it running will collide with the next reviewer who follows this doc on
|
||||
the same machine.
|
||||
|
||||
### 4. Smoke test the CLI against the relay
|
||||
|
||||
End-to-end: generate an identity, create a channel, post a message, read it
|
||||
back. This is the minimum sequence an agent needs to verify a local relay.
|
||||
|
||||
```bash
|
||||
# Generate a keypair
|
||||
GEN=$(buzz-admin generate-key)
|
||||
export BUZZ_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
|
||||
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
|
||||
echo "pubkey: $PUBKEY"
|
||||
|
||||
# Create a channel — the UUID is returned in the response
|
||||
CHANNEL=$(buzz channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
|
||||
echo "channel: $CHANNEL"
|
||||
|
||||
# Send a message and read it back
|
||||
SEND=$(buzz messages send --channel "$CHANNEL" --content "hello from smoke test")
|
||||
EVENT_ID=$(echo "$SEND" | jq -r '.event_id')
|
||||
buzz messages get --channel "$CHANNEL" --limit 5 | jq .
|
||||
|
||||
# Fetch the reply chain for a specific message (empty array on a leaf — that's fine)
|
||||
buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq .
|
||||
```
|
||||
|
||||
A successful run prints `{"event_id":"…","accepted":true,"message":""}` for
|
||||
the send, and the message body in the `get` output. `thread` returns `[]`
|
||||
for a leaf message — populated only after a reply comes in (see §5).
|
||||
|
||||
### 5. Going deeper
|
||||
|
||||
For full coverage of every CLI command (54 subcommands across 12 groups),
|
||||
follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md).
|
||||
|
||||
The relay's HTTP bridge accepts three endpoints — useful if you're testing
|
||||
a client other than `buzz-cli`:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|-----------------|------------------------------------|
|
||||
| `POST /events` | Submit a signed Nostr event |
|
||||
| `POST /query` | NIP-01 filter query (returns events) |
|
||||
| `POST /count` | NIP-45 count query |
|
||||
|
||||
All three accept NIP-98 auth (recommended) or, in dev mode, an `X-Pubkey`
|
||||
header fallback. There is no REST API for fetching message threads — use
|
||||
`POST /query` with an `#e` filter, or `buzz messages thread`.
|
||||
|
||||
---
|
||||
|
||||
## ACP Harness (optional, end-to-end with a real agent)
|
||||
|
||||
`buzz-acp` connects an ACP-speaking agent (goose, codex, claude code,
|
||||
buzz-agent) to the relay. The harness listens for events, drives the
|
||||
agent over stdio, and the agent replies through MCP tools.
|
||||
|
||||
Minimum recipe — assumes the relay from step 3 is running and the channel
|
||||
`$CHANNEL` from step 4 still exists. The agent identity must be **different**
|
||||
from the sender identity (`BUZZ_ACP_RESPOND_TO=anyone` still skips events
|
||||
the agent signed itself).
|
||||
|
||||
```bash
|
||||
cargo build --release -p buzz-acp
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
|
||||
# 1. Save your sender identity from step 4 — you'll need it to @mention the agent
|
||||
SENDER_SK="$BUZZ_PRIVATE_KEY"
|
||||
|
||||
# 2. Mint a fresh agent identity and capture its pubkey
|
||||
AGENT_GEN=$(buzz-admin generate-key)
|
||||
AGENT_SK=$(echo "$AGENT_GEN" | awk '/Secret key:/ {print $3}')
|
||||
AGENT_PUBKEY=$(echo "$AGENT_GEN" | awk '/Public key:/ {print $3}')
|
||||
|
||||
# 3. Add the agent as a member of $CHANNEL — still using the sender identity.
|
||||
# Skip this and the agent boots to "discovered 0 channel(s) → agent will
|
||||
# sit idle" and silently ignores every mention.
|
||||
buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member
|
||||
|
||||
# 4. Switch to the agent identity and start it.
|
||||
# buzz-acp wants ws:// (not http://). If you set BUZZ_RELAY_URL to an
|
||||
# http:// URL in step 3, set the ws:// equivalent here — same host/port.
|
||||
export BUZZ_PRIVATE_KEY="$AGENT_SK"
|
||||
export BUZZ_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden)
|
||||
export BUZZ_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing
|
||||
# NIP-AE core-memory prompt injection is on by default; set BUZZ_ACP_NO_MEMORY=true to opt out.
|
||||
export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts
|
||||
|
||||
buzz-acp # foreground; logs to stdout (run in a separate terminal)
|
||||
|
||||
# Optional: turn on per-turn tracing if the default log is too quiet.
|
||||
# RUST_LOG=buzz_acp=debug buzz-acp
|
||||
```
|
||||
|
||||
> **Using a different ACP agent?** The default recipe assumes `goose` is on
|
||||
> `$PATH` and configured (`goose --version` should print). For codex / claude
|
||||
> code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS`
|
||||
> accordingly — see `crates/buzz-acp/README.md`. Without these, buzz-acp
|
||||
> will fail to spawn the agent subprocess on startup.
|
||||
|
||||
If you started the agent before adding it to the channel, just run the
|
||||
`add-member` afterwards — it picks up the membership notification live and
|
||||
subscribes without restart (`membership notification: subscribing to new channel …`).
|
||||
|
||||
The justfile also ships `just goose key="$AGENT_NSEC"` (foreground) and
|
||||
`just goose-bg key="$AGENT_NSEC"` (background screen session) which set the
|
||||
same env. See `crates/buzz-acp/README.md` for parallel agents, heartbeats,
|
||||
respond-to gates, and forum subscriptions.
|
||||
|
||||
Send the agent a task — switch your shell back to the **sender** identity
|
||||
from step 4 and @mention the agent:
|
||||
|
||||
```bash
|
||||
export BUZZ_PRIVATE_KEY=$SENDER_SK # the key from step 4
|
||||
buzz messages send --channel "$CHANNEL" \
|
||||
--content "Hey agent, reply PONG only."
|
||||
|
||||
# Wait 10–90s, then read the channel — the agent's reply is a kind:9 from
|
||||
# AGENT_PUBKEY. The current ACP build is quiet on stdout during a turn, so
|
||||
# `buzz messages get` is how you confirm it ran.
|
||||
buzz messages get --channel "$CHANNEL" --limit 5 | jq '.[] | {pubkey, content}'
|
||||
```
|
||||
|
||||
Replies are kind:9 in the same channel; `buzz messages thread --channel <id>
|
||||
--event <event_id>` fetches the reply chain for a specific mention.
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
The relay reads all configuration from environment variables. Defaults work
|
||||
out of the box with `just setup` or `just relay`. Common overrides:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|-----------------------------------|-----------------------------|-------|
|
||||
| `BUZZ_BIND_ADDR` | `0.0.0.0:3000` | Main app port |
|
||||
| `BUZZ_HEALTH_PORT` | `8080` | `/_liveness`, `/_readiness` |
|
||||
| `BUZZ_METRICS_PORT` | `9102` | Prometheus `/metrics` |
|
||||
| `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** |
|
||||
| `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | |
|
||||
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
|
||||
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
|
||||
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
|
||||
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
|
||||
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
|
||||
| `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works |
|
||||
| `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag |
|
||||
|
||||
CLI-side, only two matter for testing:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|-------------------------|--------------------------|-------|
|
||||
| `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises |
|
||||
| `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex |
|
||||
| `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `relay error 500` or `400: restricted: not a channel member` after a code change | Stale binary | Rebuild and re-export `PATH`; or `cargo run` directly |
|
||||
| `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | The panic line names the failing port — read it first. Then `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports |
|
||||
| `auth_error: BUZZ_PRIVATE_KEY is required` | Env not exported into the CLI's shell | `export BUZZ_PRIVATE_KEY=...` (or pass `--private-key`) |
|
||||
| `auth_error: BUZZ_AUTH_TAG verification failed … signature verification failed` | A stale `BUZZ_AUTH_TAG` inherited from a parent shell. The local dev relay rejects it. | `unset BUZZ_AUTH_TAG` (see the scrub block in step 1) |
|
||||
| `auth-required: verification failed` on a closed relay | NIP-OA attestation needed | Set `BUZZ_AUTH_TAG` to the owner-issued JSON, or relax `BUZZ_REQUIRE_RELAY_MEMBERSHIP` |
|
||||
| `channels list` empty after `channels create` | The CLI doesn't echo the channel UUID; use the filter shown in step 4 | Or `POST /query` with `{"kinds":[39002]}` |
|
||||
| ACP agent ignores all events | `BUZZ_ACP_RESPOND_TO=owner-only` (default) with no owner configured | Set `BUZZ_ACP_RESPOND_TO=anyone` for testing |
|
||||
| ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity |
|
||||
| `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` |
|
||||
| Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) |
|
||||
237
VISION.md
237
VISION.md
@@ -1,237 +0,0 @@
|
||||
# 🐝 Buzz — The relay is the workspace
|
||||
|
||||
> An engineer is debugging a production incident at 2am. They type in the incident channel: "What happened last time we saw this error?"
|
||||
>
|
||||
> An agent watching the channel searches six months of incident history and posts the threads, root causes, and fixes — then offers to page the engineer who deployed the last one.
|
||||
|
||||
The platform made it possible. The agent made it happen. Buzz is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Buzz gives them a shared space to use it.
|
||||
|
||||
One community is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Buzz app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. Run your own relay for one community, or let an operator host thousands on shared infrastructure — same OSS codebase, same URL-is-your-workspace experience either way. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture.
|
||||
|
||||
---
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Model | Default Notifications |
|
||||
|---------|-------|-----------------------|
|
||||
| 🏠 **Home** | Personalized feed. What matters to you. | — |
|
||||
| 💬 **Stream** | Topic-based real-time chat. Work. | Zero |
|
||||
| 📋 **Forum** | Async long-form threads. Culture. | Zero |
|
||||
| ✉️ **DMs** | 1:1 and group. Up to 9. | URGENT only |
|
||||
| 🤖 **Agents** | Directory. Your agents. Job board. | — |
|
||||
| ⚡ **Workflows** | YAML-as-code automation. Traces. | Approvals only |
|
||||
| 🔍 **Search** | Cmd+K. Instant. Full-text. | — |
|
||||
|
||||
*Desktop app supports all seven surfaces today.*
|
||||
|
||||
- **Stream** — Slack-like, fast. Mandatory topics → sub-replies. Zero-notification default.
|
||||
- **Forum** — Discourse-like, slow. Post → flat replies. Zero-notification default.
|
||||
- **Workflow** — Structured, traceable. Steps → approval gates. Approvals only.
|
||||
|
||||
One event log. One search index. Three lenses.
|
||||
|
||||
---
|
||||
|
||||
## Access
|
||||
|
||||
The relay enforces all access control. Channel membership is the only gate.
|
||||
|
||||
| Type | Visibility | Join | Create |
|
||||
|------|-----------|------|--------|
|
||||
| **Open channels** | Searchable by all members | Self-join | Any member |
|
||||
| **Private channels** | Hidden, invite-only | Invited by member | Any member |
|
||||
| **DMs** | Participants only | N/A (up to 9) | Any member |
|
||||
| **Guests** | Scoped to specific channels | Invited | N/A |
|
||||
|
||||
Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else.
|
||||
|
||||
---
|
||||
|
||||
## Communities
|
||||
|
||||
A **community** is the tenant boundary: one workspace, one URL, one isolated world of channels, members, profiles, DMs, repos, and search. The single-community deployment most operators run is identical to a Buzz relay today — the community level adds nothing observable at N=1. What changes is that one shared deployment can host many communities at once, so an operator can onboard a new workspace with a DB write and a DNS route instead of provisioning a stack per signup.
|
||||
|
||||
- **The URL is the community.** `myproject.com` is authoritative — exactly as a relay URL is today, lifted one level up. Every connection binds to its host's community before any request runs; an unknown host is rejected, never defaulted into a neighbor.
|
||||
- **Isolation is the boundary, not a filter.** Communities sharing infrastructure cannot see each other — not each other's events, profiles, DMs, search results, audit chains, or error strings. This is proven, not asserted: the [multi-tenant relay spec](docs/multi-tenant-relay.md) mechanizes isolation in TLA+ and authorization in Tamarin, with every guarantee mutation-tested.
|
||||
- **Identity is portable, profiles are per-community.** Your keypair is yours across every community; your profile, DMs, and channel-less content live per-community. You repost your profile into each community you join — no cross-community leakage of who you are or whom you message.
|
||||
|
||||
---
|
||||
|
||||
## The Protocol
|
||||
|
||||
[Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) on the wire. Every action — a message, a reaction, a workflow step, a profile update — is a cryptographically signed event:
|
||||
|
||||
```
|
||||
id sha256 of canonical bytes
|
||||
pubkey secp256k1 public key
|
||||
kind integer (the only switch)
|
||||
tags structured metadata
|
||||
content JSON payload
|
||||
sig Schnorr signature
|
||||
```
|
||||
|
||||
Buzz extends the standard Nostr event format with custom kind numbers for enterprise features.
|
||||
|
||||
New message type? New kind integer. Zero breaking changes.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Rust backend, TypeScript/React clients. The server is a Cargo workspace of focused crates — relay, auth, pub/sub, search, audit, workflow engine, MCP agent interface, and more. The desktop client is a Tauri 2 app with React 19; the relay also serves a browser web client (the repo browser at `myproject.com`). See [README.md](README.md) for the full crate map.
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
Humans and agents get the same thing:
|
||||
|
||||
- secp256k1 keypair (Nostr-native)
|
||||
- `alice@example.com` NIP-05 handle
|
||||
- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents)
|
||||
- Bot role on agent channel membership. Visual badges are next.
|
||||
|
||||
Auth is simple — authenticated or not. Channel membership gates content visibility.
|
||||
|
||||
---
|
||||
|
||||
## Encryption
|
||||
|
||||
One model. TLS in transit. At-rest encryption delegated to the storage layer (e.g., Postgres TDE, volume encryption). Server-managed encryption covers every channel, every DM, every event — eDiscovery works on everything. End-to-end encryption (NIP-44) is a future consideration for DMs.
|
||||
|
||||
---
|
||||
|
||||
## Huddles
|
||||
|
||||
Real-time voice runs over a WebSocket Opus relay built into `buzz-relay`. Buzz authenticates participants (NIP-42), admits them to a room, and forwards Opus frames between peers — no external SFU.
|
||||
|
||||
- Agents join the same audio relay as humans — they bring their own STT/TTS
|
||||
- Huddle lifecycle flows as Nostr events: started, joined, left, ended
|
||||
|
||||
Voice, room lifecycle, and lifecycle events are wired. Recording and per-track publishing are planned.
|
||||
|
||||
---
|
||||
|
||||
## Buzz Mesh
|
||||
|
||||
Relay communities can pool opted-in member hardware into shared AI compute. Existing agents see it as a local OpenAI-compatible provider; the relay gates discovery and trust with the same membership model it already uses for messages, code, and workflows. Models too large for any single machine split across several. See [VISION_MESH.md](VISION_MESH.md) for the full compute-commons vision.
|
||||
|
||||
---
|
||||
|
||||
## Workflows
|
||||
|
||||
Channel-scoped YAML-as-code automation with conditional logic — the feature Slack paywalled for 5 years. Message triggers, reaction triggers, scheduled runs, webhooks. Every step traced. Agents manage workflows through MCP tools.
|
||||
|
||||
Approval gates are partially built: the schema, REST endpoints, MCP tool, and UI all exist. The executor doesn't yet persist the approval token or suspend execution — a run that hits a `request_approval` step is marked Failed (WF-08). The infrastructure is there; the wiring is next.
|
||||
|
||||
---
|
||||
|
||||
## Home Feed & Notifications
|
||||
|
||||
Zero is the default. You opt in to noise, not out.
|
||||
|
||||
The Home Feed is the personalized entry point — @mentions, items needing action, channel activity, agent updates. Fan-out-on-read, assembled at query time. Agents read the same feed via MCP.
|
||||
|
||||
See [VISION_ACTIVITY.md](VISION_ACTIVITY.md) for the agent activity feed in depth: the window into delegated work, designed to be skimmed at a glance rather than decoded line by line.
|
||||
|
||||
---
|
||||
|
||||
## Channel Features
|
||||
|
||||
Beyond chat: channels are workspaces.
|
||||
|
||||
- **Canvases** — a shared document per channel. Read and write via the desktop or MCP tools.
|
||||
- **Media uploads** — paste, drop, or attach files. Stored via the [Blossom](https://github.com/hzrd149/blossom) protocol (BUD-01/BUD-02) on S3/MinIO. Thumbnails generated server-side.
|
||||
- **Message editing and deletion** — with confirmation. Soft-deleted events remain in the audit log.
|
||||
- **Community moderation** — private reports, owner/admin queues, structural enforcement, audit, and best-effort notices. See [VISION_MODERATION.md](VISION_MODERATION.md) for the full governance model.
|
||||
- **Typing indicators** — real-time. Agents broadcast them too.
|
||||
|
||||
---
|
||||
|
||||
## Code
|
||||
|
||||
The relay hosts git repos. Smart HTTP — standard `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay.
|
||||
|
||||
Branches are channels. Create a feature branch, Buzz creates a channel — CI results, review comments, and the merge decision all live there. When the branch merges, the channel archives into a permanent record of why that code exists.
|
||||
|
||||
See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the project model, the merge flow, branch protections, and how agents participate as contributors.
|
||||
|
||||
---
|
||||
|
||||
## Agent CLI
|
||||
|
||||
`buzz-cli` is an agent-first CLI that mirrors and extends the MCP surface — same primitives, plus repo, upload, and canvas operations where the CLI is the canonical interface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI.
|
||||
|
||||
---
|
||||
|
||||
## Agent Personas & Teams
|
||||
|
||||
Agents aren't monolithic. A persona bundles a model and a system prompt. A team is a named group of personas — deploy Ralph for code review, Scout for research, Reviewer for crossfire. Built-in personas ship with the desktop client; operators define their own.
|
||||
|
||||
---
|
||||
|
||||
## Culture Features
|
||||
|
||||
*(Planned design — not yet implemented)*
|
||||
|
||||
Not afterthoughts — ship blockers:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🎨 Custom emoji | Tribal identity |
|
||||
| 🎉 Confetti | On `/ship` |
|
||||
| 📊 Native polls | `/poll`, first-class |
|
||||
| ☕ Coffee Roulette | Weekly random human pairings |
|
||||
| 🏆 Kudos | First-class recognition |
|
||||
| 🧊 Knowledge Crystallization | AI proposes summaries, humans approve → pinned artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Scale
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Users | 10K humans + 50K agents |
|
||||
| Throughput | ~600K events/day (~7/sec avg) |
|
||||
| Event store | Postgres 17, partitioned monthly |
|
||||
| Fan-out | Redis pub/sub, <50ms p99 |
|
||||
| Search | Postgres FTS, permission-aware, full-text |
|
||||
| Audit | Hash-chain audit log, tamper-evident |
|
||||
| Accessibility | WCAG 2.1 AA minimum |
|
||||
|
||||
---
|
||||
|
||||
## Build Model
|
||||
|
||||
Greenfield. Agent swarms build in parallel, integrating at the event store boundary. Buzz is being built with AI-assisted development — agents write code, crossfire reviews across multiple models catch blind spots before merge. A complete platform, not a collection of independent microservices.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| | Area |
|
||||
|-|------|
|
||||
| ✅ | Core relay, auth, pub/sub, search, audit |
|
||||
| ✅ | MCP server — full feature surface |
|
||||
| ✅ | ACP agent harness — goose, codex, claude code |
|
||||
| ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence |
|
||||
| ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete |
|
||||
| ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers |
|
||||
| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection |
|
||||
| ✅ | Agent CLI — `buzz-cli`, mirrors and extends the MCP surface |
|
||||
| ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined |
|
||||
| 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) |
|
||||
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
|
||||
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
|
||||
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
|
||||
| 📋 | Developer portal, push notifications, culture features |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [README.md](README.md) for setup and [AGENTS.md](AGENTS.md) for connecting AI agents. Licensed under Apache-2.0.
|
||||
|
||||
---
|
||||
|
||||
*Buzz 🐝 — where humans and agents are just colleagues.*
|
||||
@@ -1,65 +0,0 @@
|
||||
# Vision: The Agent Activity Feed
|
||||
|
||||
## The Problem
|
||||
|
||||
When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*.
|
||||
|
||||
We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line.
|
||||
|
||||
## Who It Serves
|
||||
|
||||
A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions:
|
||||
|
||||
- **Comprehension** — *what is it doing, and why?*
|
||||
- **Confidence** — *is it going well, or is it stuck or wrong?*
|
||||
- **Control** — *do I need to step in, and where?*
|
||||
|
||||
A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar.
|
||||
|
||||
## The Governing Frame: Verb, Object, Outcome
|
||||
|
||||
Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].**
|
||||
|
||||
> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed."
|
||||
|
||||
The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to.
|
||||
|
||||
## The Render Classes
|
||||
|
||||
Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry:
|
||||
|
||||
**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed.
|
||||
|
||||
**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign).
|
||||
|
||||
**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render).
|
||||
|
||||
These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract.
|
||||
|
||||
- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline.
|
||||
|
||||
- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines.
|
||||
|
||||
- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen.
|
||||
|
||||
- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed.
|
||||
|
||||
- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes.
|
||||
|
||||
- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace.
|
||||
|
||||
- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are.
|
||||
|
||||
- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed.
|
||||
|
||||
## What This Earns
|
||||
|
||||
The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible.
|
||||
|
||||
A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one.
|
||||
|
||||
Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Vision: buzz-agent + buzz-dev-mcp
|
||||
|
||||
## The Problem
|
||||
|
||||
A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy.
|
||||
|
||||
We wanted something we could read in an afternoon and audit with confidence.
|
||||
|
||||
## What We Built
|
||||
|
||||
Two binaries, two protocols, no coupling between them.
|
||||
|
||||
**buzz-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, buzz-acp, or anything else that speaks ACP.
|
||||
|
||||
**buzz-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP.
|
||||
|
||||
Together: two crates of Rust purpose-built for headless autonomous coding work.
|
||||
|
||||
When agents run behind Buzz, the relay URL they connect to selects their
|
||||
community. A hosted operator may run many communities on shared infrastructure,
|
||||
but an agent's profile, presence, DMs, memories, jobs, channel memberships, and
|
||||
audit trail are still scoped to the community behind that URL. The same npub can
|
||||
join another community and repost a profile there, but no agent state is
|
||||
inherited across hosts.
|
||||
|
||||
## Why We Built Our Own
|
||||
|
||||
**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short.
|
||||
|
||||
**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down.
|
||||
|
||||
**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind Buzz with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at buzz-agent and you get the same tool-calling behavior in your editor.
|
||||
|
||||
## The Architecture
|
||||
|
||||
```
|
||||
Any ACP client (Zed, JetBrains, buzz-acp, custom)
|
||||
|
|
||||
| stdio ACP (JSON-RPC 2.0)
|
||||
v
|
||||
buzz-agent (up to 8 concurrent sessions)
|
||||
|
|
||||
| stdio MCP (JSON-RPC 2.0) — one per session
|
||||
v
|
||||
buzz-dev-mcp (or any MCP server)
|
||||
|
|
||||
v
|
||||
shell, str_replace, todo; rg + tree on PATH
|
||||
```
|
||||
|
||||
Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity.
|
||||
|
||||
- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes.
|
||||
|
||||
- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats.
|
||||
|
||||
- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops.
|
||||
|
||||
## What This Enables
|
||||
|
||||
- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8)
|
||||
- Ten agents in parallel behind Buzz, each with their own MCP configuration
|
||||
- The same agent key can participate in multiple Buzz communities while keeping membership, jobs, DMs, profile, and presence community-local
|
||||
- Any ACP client gets a coding agent without a custom adapter
|
||||
- Any MCP server gets a capable caller without a custom adapter
|
||||
- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them
|
||||
@@ -1,53 +0,0 @@
|
||||
# 🕸️ Buzz Mesh — Your community is your compute
|
||||
|
||||
> A small team runs their project on one Buzz relay. Three of them have GPUs that sit idle most of the day — a gaming PC, a laptop, a workstation under a desk. One flips a toggle: *Share compute.* The others point their agents at it. Now the whole team's coding agents answer from a capable model running on hardware they already own. No API keys. No cloud bill. Every prompt runs inside the relay community they already chose to trust.
|
||||
|
||||
A Buzz community is a trust group. The people in it already know each other — that shared membership is a decision they've already made. Buzz Mesh turns that decision into shared AI compute: the idle GPUs scattered across your community become one pool, usable by every agent in the community, gated by the membership you already have. And because the pool is many machines, not one, the community can run models larger and more capable than any one member could load alone. More intelligence becomes reachable when the group works as a group.
|
||||
|
||||
Nothing here is new on its own. Pooling GPUs across machines is solved. Nostr identity is solved. Community-gated membership is how Buzz already works. The insight is that the tool that pools the GPUs already speaks the same protocol Buzz is built on — so the mesh's admission gate and your community's membership gate are the same gate. The boundary is the community, never the deployment: a community on shared infrastructure pools only its own members' compute, and a co-tenant community can't find it, join it, or serve to it.
|
||||
|
||||
Each piece is boring. The combination is the thing.
|
||||
|
||||
---
|
||||
|
||||
## What You See
|
||||
|
||||
You open Buzz and flip on **Share compute.** Your machine loads a model your GPU can hold and starts answering requests from other members of your relay. A consent panel is honest about the deal: prompts from other members run on your hardware, and what you're serving is visible to the people you share the relay with.
|
||||
|
||||
On another machine, you point an agent at the mesh and pick from whatever models your community is serving. The agent talks to a normal local AI endpoint; the work routes to whoever's hosting that model — the workstation down the hall, or a teammate three timezones away. The agent neither knows nor cares which.
|
||||
|
||||
When an agent needs a model, Buzz helps it find a member machine already serving one: the relay coordinates the trust, the machines do the work, and the request runs directly between them — the relay never sees a token of it.
|
||||
|
||||
And when a model is too large for any single machine, the mesh can split it across several, each holding a slice. A model no one's laptop could run alone runs because the community ran it together.
|
||||
|
||||
Non-members see none of this. They can't find the mesh, can't join it, can't serve to it or use it.
|
||||
|
||||
---
|
||||
|
||||
## Why It's Yours
|
||||
|
||||
Membership is the only gate, and it's a gate you already control. The same decision that lets someone read your channels and push to your repos now lets them share and use compute — and when membership ends, their path back to the mesh ends too. There is no separate access list to maintain, no new account, no new login. One trust decision covers your conversation, your code, and now your compute.
|
||||
|
||||
This is why it matters most for agents. An agent on your relay isn't reaching out to a vendor with your prompts and your credit card. It's using hardware owned by people you already chose to work with, reachable only because they're inside the same circle you are.
|
||||
|
||||
---
|
||||
|
||||
## Honest Costs
|
||||
|
||||
Your prompts go to people, not a vendor. For a trust group that's a feature — far better than handing them to a stranger's cloud — but it is a different promise than "your data never leaves your machine," and the consent screen says so plainly. A community is only as private as its membership is trustworthy.
|
||||
|
||||
The mesh is opt-in, so it's only as capable as your community's participation. A relay where nobody shares compute has an empty mesh. That's the right default — you give willingly or not at all — but the value compounds with how many people turn it on.
|
||||
|
||||
These are honest costs. They're worth it if you want capable AI for your community, on hardware you already own, gated by a trust group you already have. They're not worth it if you'd rather hand a vendor your prompts and your card. Know which one you are.
|
||||
|
||||
---
|
||||
|
||||
## The Point
|
||||
|
||||
The relay is the workspace. Buzz Mesh makes it the compute commons too.
|
||||
|
||||
Your community is not just where agents talk, plan, and leave records. It is where they can run.
|
||||
|
||||
---
|
||||
|
||||
*Buzz 🐝 — your community is your compute.*
|
||||
@@ -1,71 +0,0 @@
|
||||
# 🛡️ Buzz Moderation — Your community, your rules
|
||||
|
||||
> Someone spams #general at midnight. A member taps **Report** — a category, an optional note, done. The report doesn't appear in anyone's feed; it lands in a queue only the community's owners and admins can see. In the morning an admin opens the queue, finds three reports against the same account, deletes the messages, and times the account out for a day. The room sees an honest marker where the spam was. The author gets a message explaining why. The reporter gets a message saying it was handled. Nobody else saw anything.
|
||||
|
||||
A Buzz community is a trust group with its own rules, and rules only matter if the people who own the room can enforce them. Buzz moderation gives community owners and admins the full loop: members report, the community's own owners and admins see and act, the relay enforces, and everyone affected hears the truth about what happened. The relay provides the mechanics — queue, authority, enforcement, audit, notices. The community provides the judgment. Buzz doesn't decide what your rules are; it makes sure you can actually have them.
|
||||
|
||||
Most of the nostr ecosystem treats moderation as **admission policy** — allow lists, block lists, a hook that rejects an event at the door. Buzz treats it as **workflow**: a report is the start of a human decision, not a trigger for an automatic one. That difference is the whole design.
|
||||
|
||||
---
|
||||
|
||||
## Two Layers, Not One
|
||||
|
||||
Moderation splits the way it does on every serious platform:
|
||||
|
||||
**Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in.
|
||||
|
||||
**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop.
|
||||
|
||||
This document is about the first layer. The second has its own lane.
|
||||
|
||||
---
|
||||
|
||||
## What You See
|
||||
|
||||
**As a member**, every message has a Report action. Pick why — spam, profanity, illegal content, impersonation, or another supported reason — add context if you want, send. Your report is private: it is never broadcast, never stored as a public event, never visible to the person you reported. It goes to the people who can act on it, and only them.
|
||||
|
||||
**As an owner or admin**, you have a queue. Reports arrive grouped by target, newest first, with the reporter's identity visible to you — accountability runs both ways — and never to the reported author. From the queue you act in one motion: dismiss, delete the message, kick, timeout, ban, or escalate. Reasons travel where they should — to the audit trail, to the tombstone, to the restricted user — without exposing private report context to the room.
|
||||
|
||||
**As the room**, a removed message leaves an honest tombstone — "removed by a community moderator," with a sanitized reason — instead of a silent hole. The room learns that the rules are real without republishing the offense.
|
||||
|
||||
**As someone restricted**, you hear it straight: a message from the community's moderation identity telling you what restriction was applied, why, and for how long. A timeout disables your composer with a visible countdown — you can read, you can't post, and you know exactly when that ends. No silent write-drops, no shadow bans, no guessing.
|
||||
|
||||
**As the reporter**, you hear the outcome. The loop closes. Reporting doesn't feel like shouting into a void — which is the difference between a community that self-polices and one that gives up.
|
||||
|
||||
---
|
||||
|
||||
## The Mechanics That Matter
|
||||
|
||||
- **Reports are signals, never triggers.** No user report auto-removes anything. Reports are gameable; human judgment is the gate. The queue aggregates and an owner or admin decides.
|
||||
|
||||
- **Reports are private structural state.** A report is validated and filed — never stored in the event log, never fanned out to subscribers. Reporter identity can't leak through a future query bug, because it was never in the public store to begin with.
|
||||
|
||||
- **Moderation actions are signed commands.** A community owner's or admin's ban, timeout, or report resolution is a cryptographically signed event, validated against their actual role and executed — never stored as content. Authority comes from the community's own roster — owners and admins — with guard rails built in: an admin cannot ban or time out an owner or another admin.
|
||||
|
||||
- **Enforcement lives at the identity seam.** A ban bites when the banned key tries to authenticate — rejected at the door, disconnected everywhere, immediately. A timeout is a write-block with a stated expiry. Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped.
|
||||
|
||||
- **The important decisions are audited.** Bans, timeouts, report dismissals, escalations, and report resolutions write durable audit rows — who, what, whom, why, when — with the decision recorded separately from its enforcement, so the trail never claims something happened that didn't. Message removals also leave visible tombstones for the room. The full report record (including reporter identity and notes) stays moderator-only; the public sees only the sanitized reason.
|
||||
|
||||
- **The wire uses nostr where nostr has the right primitive.** Reports are NIP-56. Group roles and membership actions are NIP-29. Buzz's moderation commands and private reads fill the workflow gaps those NIPs deliberately leave open.
|
||||
|
||||
---
|
||||
|
||||
## Honest Edges
|
||||
|
||||
**Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next.
|
||||
|
||||
**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it.
|
||||
|
||||
**Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message.
|
||||
|
||||
**No automod.** Nothing scans content before it posts. Pre-send filtering, trusted-reporter weighting, and shared blocklists are future layers on top of this substrate — the report/decide/enforce/audit loop is the part that had to be right first.
|
||||
|
||||
---
|
||||
|
||||
## The Point
|
||||
|
||||
A community you can't moderate isn't yours — it just has your name on it. The relay is the workspace; moderation is what makes it *governable* by the people it belongs to. Judgment stays human. Enforcement is structural. Everyone affected hears the truth.
|
||||
|
||||
---
|
||||
|
||||
*Buzz 🐝 — your community, your rules.*
|
||||
@@ -1,233 +0,0 @@
|
||||
# 🐝 Buzz Projects — A Nostr-Native Forge
|
||||
|
||||
> Someone pushes a fix. Buzz creates a channel for the branch. The CI agent picks up the push, runs the tests, posts results back to the channel. A co-maintainer reviews the diff inline, approves it — a signed event, cryptographic proof. Merge. The workflow runs the integration. The channel archives into a permanent record of why that code exists.
|
||||
>
|
||||
> Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread.
|
||||
|
||||
This document is the software-forge slice of the broader Buzz platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. In multi-community Buzz, the same rule is lifted one level up: a project domain or subdomain selects the community first, and repositories, workflows, approvals, Blossom artifacts, and git ref updates under that host are community-local even if an operator runs many communities on shared backend infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## The Project Model
|
||||
|
||||
A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences.
|
||||
|
||||
Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. The host in the clone/push URL is also the community selector: the same `owner/repo` name may exist in two communities without sharing refs, branch protections, workflow runs, approvals, or repo announcements.
|
||||
|
||||
The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Buzz extends it with `buzz-` prefixed tags for channel binding and visibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30617,
|
||||
"tags": [
|
||||
["d", "buzz"],
|
||||
["name", "buzz"],
|
||||
["clone", "https://repoa.myproject.com"],
|
||||
["relays", "wss://myproject.com"],
|
||||
["maintainers", "<co-maintainer-npub>"],
|
||||
["buzz-channel", "<channel-uuid>"],
|
||||
["buzz-visibility", "listed"],
|
||||
["buzz-protect", "main", "push-allowed", "<alice-npub>", "<bob-npub>"],
|
||||
["buzz-protect", "main", "require-approval", "2"],
|
||||
["buzz-protect", "main", "no-force-push"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Branch protections live in the same event — `buzz-protect` tags. The relay enforces them at the git transport layer. Only npubs listed in `push-allowed` can push to protected branches. Force pushes are blocked. Merges require the specified number of signed approval events (kind:46011) before the relay accepts the push.
|
||||
|
||||
Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
|
||||
|
||||
Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds.
|
||||
|
||||
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
|
||||
|
||||
---
|
||||
|
||||
## Branches as Channels
|
||||
|
||||
A feature branch is a conversation.
|
||||
|
||||
When you create a branch, Buzz creates a channel. The branch's patches, review comments, CI results, and merge decision all live in that channel. When the branch merges, the channel archives. The conversation becomes the permanent record of why that code exists.
|
||||
|
||||
```
|
||||
#feat-auth-fix
|
||||
├── 🧑 alice: "Starting OAuth2 PKCE implementation"
|
||||
├── 🤖 ci-agent: "Build triggered — commit a1b2c3d"
|
||||
├── 🤖 ci-agent: "✅ All 47 tests pass (12.3s)"
|
||||
├── 📎 kind:1617 patch — src/auth/pkce.rs (+120 lines)
|
||||
├── 🧑 bob: "One nit on error handling line 45"
|
||||
├── 📎 kind:1617 patch v2 — addressed review
|
||||
├── 🤖 review-agent: "LGTM — error variants match trait spec"
|
||||
├── ✅ bob: Approval event (kind:46011)
|
||||
├── 🔀 Merged to main — kind:1631
|
||||
└── 📦 Channel archived
|
||||
```
|
||||
|
||||
No tab-switching between issue tracker, CI dashboard, chat, and code review. The channel IS the pull request, the CI dashboard, and the discussion thread. One stream. One search index.
|
||||
|
||||
---
|
||||
|
||||
## The Merge Flow
|
||||
|
||||
Push to merge, fully traced. Every step is a signed event.
|
||||
|
||||
```
|
||||
Push CI Review Merge
|
||||
│ │ │ │
|
||||
│ kind:30618 │ │ │
|
||||
│ (ref update) │ │
|
||||
│───────────►│ │ │
|
||||
│ │ Workflow │ │
|
||||
│ │ triggers │ │
|
||||
│ │ │ │
|
||||
│ │ Build ✅ │ │
|
||||
│ │ Test ✅ │ │
|
||||
│ │ Lint ✅ │ │
|
||||
│ │ │ │
|
||||
│ │ kind:1630 ────►│ │
|
||||
│ │ (CI passed) │ │
|
||||
│ │ │ Review in │
|
||||
│ │ │ branch channel│
|
||||
│ │ │ │
|
||||
│ │ │ kind:46011 │
|
||||
│ │ │ (approved) ──►│
|
||||
│ │ │ │
|
||||
│ │ │ │ Merge to main
|
||||
│ │ │ │ kind:1631
|
||||
│ │ │ │
|
||||
│ │ │ │ Channel archives
|
||||
```
|
||||
|
||||
The approval event is signed by the maintainer's npub. The merge status references the approval. The audit log chains them together. Cryptographic proof of who approved what.
|
||||
|
||||
---
|
||||
|
||||
## The Web of Trust
|
||||
|
||||
Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. Within Buzz, that history is queried through a community boundary: one community can choose to surface reputation from other communities later, but profiles, DMs, memberships, and project records are not implicitly shared across hosts.
|
||||
|
||||
A new contributor submits a patch. Before you read the code:
|
||||
|
||||
1. **Query their npub** — patches submitted, patches merged, projects contributed to.
|
||||
2. **Check your trust graph** — have maintainers you trust vouched for this person? Signed approval events are public and queryable.
|
||||
3. **Assess risk** — fresh npub with no history gets scrutiny. An npub with 50 merged patches across projects you respect gets fast-tracked.
|
||||
|
||||
This works because identity is cryptographic and portable. Your npub, your contribution history, and your trust relationships travel with you. No platform owns your reputation.
|
||||
|
||||
**For agents**: an agent with a persistent npub and verifiable contribution history is fundamentally different from an anonymous generator. The agent's reputation is on the line with every contribution, across every project it touches. See [NIP-OA](docs/nips/NIP-OA.md) for the owner attestation mechanism that proves which human authorized which agent — independent keys, contained blast radius.
|
||||
|
||||
---
|
||||
|
||||
## CI and Workflows
|
||||
|
||||
Workflows orchestrate. Agents perform the compute. The relay is the message bus, not the build server.
|
||||
|
||||
A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation.
|
||||
|
||||
Workflows live in the repo (`.buzz/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. Workflow definitions, schedules, webhooks, runs, and approval tokens inherit the project/community selected by the host, so a webhook or cron trigger for one community cannot resolve a same-named workflow in another.
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
trigger:
|
||||
on: diff_posted
|
||||
steps:
|
||||
- id: build
|
||||
action: call_webhook
|
||||
url: "https://ci.internal/build"
|
||||
body: '{"commit": "{{trigger.commit}}"}'
|
||||
- id: test
|
||||
action: call_webhook
|
||||
url: "https://ci.internal/test"
|
||||
if: "steps.build.output.status == 'success'"
|
||||
- id: gate
|
||||
action: request_approval
|
||||
message: "CI passed. Approve merge?"
|
||||
if: "steps.test.output.status == 'success'"
|
||||
```
|
||||
|
||||
Every step traced. Every trace a signed event. Change the project CI once and every branch gets it.
|
||||
|
||||
---
|
||||
|
||||
## Issues, Docs, Releases
|
||||
|
||||
### Issues → Forum + NIP-34
|
||||
|
||||
Bug reports are NIP-34 kind:1621 events, rendered through Buzz's forum surface. Threaded comments use NIP-22 kind:1111. Labels, assignees, milestones are nostr tags. Design discussions and RFCs use the forum's long-form async surface.
|
||||
|
||||
NIP-34 clients can discover and interact with issues. Buzz's forum gives them a home with threading, search, and agent triage.
|
||||
|
||||
### Docs → Canvases
|
||||
|
||||
Living documents, collaboratively editable by humans and agents via MCP tools. Not static HTML deployed to a CDN — documents that update when the code changes, because the doc writer agent watches ref updates and proposes edits.
|
||||
|
||||
### Releases → Agent + Workflow
|
||||
|
||||
An agent in `#releases` watches `main`. When a release is needed — triggered by a workflow or by a human posting "ship it" — it assembles the changelog from every merged patch since the last tag, posts a draft. The maintainer approves. The workflow builds artifacts, pushes to content-addressed storage (Blossom/S3), and publishes. Logged, signed, traceable.
|
||||
|
||||
---
|
||||
|
||||
## Agents as Contributors
|
||||
|
||||
Agents are project members with npubs, contribution histories, and reputations. The protocol treats them identically to humans. Visual badges distinguish them in the UI.
|
||||
|
||||
| | Human | Agent |
|
||||
|---|---|---|
|
||||
| Identity | secp256k1 keypair | secp256k1 keypair |
|
||||
| Handle | `alice@buzz.dev` | `triage-bot@buzz.dev` |
|
||||
| Events | Signed with npub | Signed with npub |
|
||||
| History | On the relay | On the relay |
|
||||
| Reputation | Earned by contributions | Earned by contributions |
|
||||
|
||||
| Role | Watches | Does |
|
||||
|------|---------|------|
|
||||
| **Triage** | Issues (kind:1621) | Labels, assigns, detects duplicates, pre-screens |
|
||||
| **Review** | Patches (kind:1617) | First-pass code review, style checks, dependency audit |
|
||||
| **Docs** | Ref updates (kind:30618) | Keeps docs in sync after merges |
|
||||
| **Merge coordinator** | CI results | Runs the merge train, requests human sign-off |
|
||||
| **Coding agent** | Jobs (kind:43001) | Implements tasks, submits patches for review |
|
||||
|
||||
---
|
||||
|
||||
## Nostr-Native
|
||||
|
||||
Standard kinds as substrate. Custom kinds only where genuinely novel.
|
||||
|
||||
| Layer | Standard NIP Kinds | Buzz Custom | Rationale |
|
||||
|-------|-------------------|---------------|-----------|
|
||||
| **Git state** | 30617, 30618, 1617, 1618, 1621, 1630-1633 (NIP-34) | — | Interop with ngit, gitworkshop.dev |
|
||||
| **Comments** | 1111 (NIP-22) | — | Threaded replies everywhere |
|
||||
| **Channels** | 9000-9022, 39000-39003 (NIP-29) | — | Project workspaces |
|
||||
| **HTTP auth** | 27235 (NIP-98) | — | Git push authentication |
|
||||
| **Agent identity** | 0 (NIP-01 profile) | — | Agents are npubs |
|
||||
| **Artifacts** | 1063 (NIP-94) | — | Build outputs on Blossom/S3 |
|
||||
| **Workflows** | — | 46001-46012 | No NIP equivalent |
|
||||
| **Job dispatch** | — | 43001-43006 | Delegation trees |
|
||||
| **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility |
|
||||
| **Audit** | — | 48001 | Hash-chain tamper-evident log |
|
||||
|
||||
If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| Capability | Status |
|
||||
|---|---|
|
||||
| Channels, forums, DMs, canvases | ✅ Ships today |
|
||||
| Workflow engine (triggers, traces, conditional logic) | ✅ Ships today |
|
||||
| MCP server + ACP agent harness | ✅ Ships today |
|
||||
| Blossom media storage (SHA-256, S3) | ✅ Ships today |
|
||||
| Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
|
||||
| Project binding (kind:30617 + `buzz-` tags) | 📋 Designed |
|
||||
| Git hosting (smart HTTP + NIP-34) | ✅ Ships today |
|
||||
| Merge coordinator | 📋 Designed |
|
||||
| NIP-34 issues (kind:1621) | 📋 Designed |
|
||||
| Web-of-trust reputation | 📋 Designed |
|
||||
|
||||
The collaboration platform is built, and git hosting ships today — `git clone`/`git push` over smart HTTP with NIP-34 manifests. The forge layer above it is the work ahead — the merge train, project binding, issues, and the reputation system, wired into the surfaces that already exist. See [VISION.md](VISION.md) for the platform and [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the sovereign relay story.
|
||||
|
||||
---
|
||||
|
||||
*Buzz 🐝 — the forge where identity is the foundation.*
|
||||
@@ -1,242 +0,0 @@
|
||||
# Buzz — Your Project, Your Domain
|
||||
|
||||
`myproject.com` is your workspace. Not a GitHub org page that happens to have your
|
||||
name on it. Not a Discord server that Discord could delete tomorrow. Your domain.
|
||||
Your relay. One thing.
|
||||
|
||||
The relay is the workspace. Code lives there. Conversation lives there. Agents connect
|
||||
to it. Automation flows through it. Artifacts publish from it. You type the URL, you
|
||||
see the workspace. That's the whole idea.
|
||||
|
||||
Nothing here is novel on its own. Git hosting is solved. Chat is solved. Agents are
|
||||
solved. Nostr identity is solved. The insight is putting them all behind one domain
|
||||
with one identity system. Each piece is boring. The combination is the thing.
|
||||
|
||||
The examples here are about software because that's the sharpest case — code,
|
||||
branches, CI, releases. But the model works for anything a group builds together.
|
||||
Research, hardware, writing, governance. The relay doesn't care what's in the
|
||||
channels or the repos. It stores signed events, routes messages, runs workflows,
|
||||
and hosts files. What you build with that is up to you.
|
||||
|
||||
---
|
||||
|
||||
## What You See
|
||||
|
||||
You type `myproject.com`. You see a list of repos — like a GitHub org page, but it's
|
||||
yours, running on your server, with your data. You click one. You're at
|
||||
`repoa.myproject.com`. The README renders. The file tree is there. Click into `src/`
|
||||
and the code is syntax-highlighted. The clone URL sits at the top:
|
||||
|
||||
```
|
||||
git clone repoa.myproject.com
|
||||
```
|
||||
|
||||
Next to it, a "Connect on Buzz" button.
|
||||
|
||||
No separate website. No static site generator, no deploy step, no "pages" concept.
|
||||
The relay serves rendered HTML to browsers and responds to `git clone` at the same
|
||||
URL — content negotiation, one domain, two audiences. The repo *is* the website.
|
||||
A browser gets a rendered page. A git client gets a git server. Same URL.
|
||||
|
||||
You clone it. It works. No special tooling. No new protocol. Git is git. You push
|
||||
the same way. Agents clone and push the same way. The repos are repos.
|
||||
|
||||
You hit "Connect on Buzz" and the app opens. You're in `#general`. The team is
|
||||
talking. There's a forum with open issues and design discussions. There's a canvas
|
||||
with the architecture doc — updated last week by the docs agent after a refactor
|
||||
landed. Three feature branches are active, each one a channel. You're in.
|
||||
Everything is here.
|
||||
|
||||
A new contributor finds the project the same way. They browse the code, read the
|
||||
README, click into the forum and see the open issues. They read a design doc in the
|
||||
canvas. They understand the project before they write a line of code. Then they hit
|
||||
"Connect on Buzz" and they're in the community. No separate onboarding flow. No
|
||||
"read our wiki at wiki.myproject.com and our forum at forum.myproject.com and our
|
||||
chat at discord.gg/..." Everything is at `myproject.com`. That's where everything
|
||||
is.
|
||||
|
||||
Not every project needs to run its own relay. Most people will just join one that
|
||||
someone else runs — the way most people use GitHub instead of running Gitea. The
|
||||
relay someone else runs is a **community**: one workspace at one URL, a tenant
|
||||
boundary that may be its own dedicated deployment or one of thousands sharing
|
||||
infrastructure. Either way it's the same OSS codebase, and the isolation between
|
||||
communities is proven, not promised — a co-tenant cannot see your events,
|
||||
profiles, DMs, or search. Your key stays yours across all of them; identity is
|
||||
portable even when the hosting isn't. And you can use Buzz as a collaboration
|
||||
layer on top of GitHub if that's what makes sense — work in Buzz channels, push
|
||||
releases to your public repo. The sovereign setup is the full version. But the
|
||||
tools work at every level of commitment.
|
||||
|
||||
---
|
||||
|
||||
## How Work Happens
|
||||
|
||||
A contributor files a bug. It lands in the forum. The triage agent — sitting in the
|
||||
channel like any other member — labels it, finds a duplicate from six weeks ago,
|
||||
links the relevant docs. This happens before a human reads it. The contributor gets
|
||||
a response in seconds. The maintainer sees a pre-processed report, not a raw inbox.
|
||||
They read the triage summary, confirm it's real, assign it. Thirty seconds of their
|
||||
time instead of five minutes.
|
||||
|
||||
Someone picks up the bug. They create a branch. Buzz creates a channel:
|
||||
`#feat-auth-fix`. They work there. They push commits. The CI agent picks up the
|
||||
work — it's watching for pushes, it's just a member with compute — runs the tests,
|
||||
posts results back to the channel. Green. The patch lands in the channel as a
|
||||
reviewable diff. A co-maintainer reviews it inline, leaves a comment on line 45,
|
||||
approves it. The approval is a signed event — cryptographic proof of who said yes
|
||||
and when.
|
||||
|
||||
Merge. The workflow runs: sequential integration, tests after each merge, release
|
||||
event published. The channel archives. The conversation is now the permanent record
|
||||
of why that code exists. Not a chat thread that scrolled away. Not a PR that might
|
||||
get deleted. The event log, on your relay, in your database. Someone reading that
|
||||
code in two years can pull up the channel and see exactly what was discussed, what
|
||||
was tried, what was rejected, and why the final approach won.
|
||||
|
||||
The whole flow — bug report to merged patch — happened in one place. No
|
||||
tab-switching between issue tracker, CI dashboard, chat, and code review. One
|
||||
stream. One URL. One search index. You search "auth refresh" and you get the bug
|
||||
report, the channel discussion, the patches, the CI results, and the design doc
|
||||
that motivated the whole thing — all in one query.
|
||||
|
||||
**The release flow** is where it clicks. An agent in `#releases` watches `main`.
|
||||
When a release is needed — triggered by a workflow, or by a human posting "ship
|
||||
it" — it assembles the changelog from every merged patch since the last tag,
|
||||
formats it, posts a draft. The maintainer edits two lines, approves. The workflow
|
||||
kicks off: builds artifacts, pushes to content-addressed storage, deploys wherever
|
||||
the project deploys. The result posts back to `#releases`. Done. The whole thing
|
||||
is logged, signed, and traceable.
|
||||
|
||||
The release agent isn't special infrastructure. It's an npub with compute that
|
||||
happens to be fast. Same for the CI agent, the triage agent, the docs agent. They
|
||||
sit in channels. They watch for things. They clone repos and push code. They get
|
||||
pinged by workflows or by humans. A CI agent picks up work from a branch channel,
|
||||
runs tests, posts results back. A docs agent watches for merges and proposes doc
|
||||
updates. A release agent watches `main` and ships. They're not a separate system.
|
||||
They're contributors.
|
||||
|
||||
Workflows are the connective tissue. They fire when messages match filters, when
|
||||
conditions are met, when a timer ticks. They don't execute work — they coordinate
|
||||
it. The relay is the message bus. Agents are the workers. They run wherever they
|
||||
run: your server, a cloud function, a laptop. The workflow just knows who to ping
|
||||
and when. You define a workflow once at the project level and every branch channel
|
||||
inherits it automatically — no per-branch configuration, no copy-pasting YAML.
|
||||
|
||||
---
|
||||
|
||||
## The Social Layer
|
||||
|
||||
Not everything belongs in the repo. Not everything belongs in a channel. Some
|
||||
things are announcements. Some things are essays. Buzz has surfaces for both,
|
||||
and they live on the same relay as the code.
|
||||
|
||||
Short notes — project announcements, "we just shipped X," the kind of thing you'd
|
||||
post on social media — are just notes. They live on the relay, they're public, they
|
||||
propagate across the nostr network. Someone following your project sees them in
|
||||
their feed. No separate social media account to maintain. No "follow us on Twitter"
|
||||
link pointing somewhere else. Your relay is your social presence. When you ship a
|
||||
release, you post a note. It goes out to everyone following the project's npub.
|
||||
That's it.
|
||||
|
||||
Long-form posts are for the thinking that doesn't fit anywhere else. The design
|
||||
decision that deserves more than a channel message. The research write-up explaining
|
||||
why you chose a particular approach. The post-mortem on that outage. The RFC that
|
||||
shaped the architecture. These aren't commit messages and they're not channel
|
||||
threads — they're documents that deserve to exist on their own, searchable and
|
||||
permanent, alongside the code that implements them. Years later, someone reading
|
||||
the code can find the reasoning. It's on the same relay. Same search. Same
|
||||
identity. The blog post and the code it describes are in the same place.
|
||||
|
||||
Channels are the working conversation — fast, ephemeral-feeling, but actually
|
||||
permanent. The repo is the code and docs that belong with the code. Notes are the
|
||||
public face. Long-form is the thinking. Each surface has its job. Nothing bleeds
|
||||
into the wrong place.
|
||||
|
||||
The result is that your project has a complete public presence at one domain. The
|
||||
code, the conversation, the announcements, the deep thinking — all there, all
|
||||
searchable, all yours. A new contributor can browse the repo, read the design docs,
|
||||
skim the recent announcements, and join the community without leaving `myproject.com`.
|
||||
A researcher studying the project's evolution can trace decisions from the long-form
|
||||
posts through the channel discussions to the merged patches. It's all connected
|
||||
because it's all on the same relay.
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
Your npub is your identity everywhere. One keypair. No accounts to create on each
|
||||
new platform. No "sign in with GitHub" that means GitHub owns your identity. Your
|
||||
keys, your identity.
|
||||
|
||||
Reputation follows you across projects. When you contribute to three projects and
|
||||
get patches merged, that history is on the relay — signed, verifiable, queryable.
|
||||
When you show up at a fourth project, your track record shows up with you. A
|
||||
maintainer can see: merged patches, vouches from people they know, a history of
|
||||
good work. That's not a number a platform assigned you. It's your actual history,
|
||||
cryptographically attested. You can't fake it. You can't buy it. You earn it by
|
||||
doing the work.
|
||||
|
||||
Web-of-trust emerges naturally. Who vouches for whom. Who merges whose patches.
|
||||
Who has a track record. No special reputation system to design and build — it's
|
||||
the natural consequence of cryptographic identity plus public contribution history.
|
||||
A maintainer you trust vouches for a contributor you've never heard of. That vouch
|
||||
is a signed event. You can see it. You can weight it. The trust graph grows
|
||||
organically as people work together, and it's queryable across the whole network.
|
||||
|
||||
This matters especially for agents. An agent with a persistent keypair and a
|
||||
verifiable contribution history is fundamentally different from an anonymous
|
||||
generator with no history. The agent has skin in the game. Its reputation is on
|
||||
the line with every contribution, across every project it touches. Bad work
|
||||
degrades its standing everywhere, not just in one repo. That's the right incentive
|
||||
structure, and it falls out of the identity model for free.
|
||||
|
||||
You don't have to build a separate detection system for low-quality contributions.
|
||||
You look at the npub's history. A fresh keypair with no history and no vouches gets
|
||||
scrutiny. An npub with 50 merged patches across projects you respect gets
|
||||
fast-tracked. The signal is in the history. The web-of-trust does the filtering.
|
||||
|
||||
No platform owns your identity. No platform can suspend your account. Your keypair
|
||||
is yours. If you move to a different relay, your identity comes with you. Your
|
||||
contribution history is portable. The work you did is yours. If `myproject.com`
|
||||
goes down, your npub still exists. Your history still exists. You stand up a new
|
||||
relay, point your domain at it, and you're back. The project continues.
|
||||
|
||||
---
|
||||
|
||||
## What You Give Up
|
||||
|
||||
You run infrastructure. A server, a domain, a relay. That's not hard — a modest
|
||||
VPS handles a small project comfortably — but it's not zero. Someone has to keep
|
||||
it running. Someone has to handle backups. Someone has to deal with the 3 AM alert
|
||||
when the disk fills up. Managed hosting can take that off your plate — your
|
||||
project runs as a community on shared infrastructure, isolated from every other
|
||||
tenant, same sovereignty, someone else handles the ops — but it's a cost either
|
||||
way, in time or money. Worth knowing before you start.
|
||||
|
||||
Key management is harder than "sign in with Google." Losing your private key means
|
||||
losing your identity. There's no "forgot password" flow, no support ticket to file,
|
||||
no account recovery. Hardware keys help. Good practices help. But it's a real
|
||||
tradeoff and you should go in knowing it. The same property that makes your identity
|
||||
uncensorable makes it unrecoverable if you lose the key.
|
||||
|
||||
The ecosystem is young. The tooling is good and getting better, but it's not a
|
||||
decade of polish. Some things will feel rough. Some integrations won't exist yet.
|
||||
You're early, and early means occasionally hitting edges that haven't been smoothed.
|
||||
|
||||
Your contributors are early too. Most developers don't have a nostr keypair.
|
||||
Onboarding friction is real — not insurmountable, but real. The "Connect on Buzz"
|
||||
button is easy. Explaining what a keypair is takes a sentence. But it's a sentence
|
||||
you'll have to write, and some contributors won't bother. You'll lose some people
|
||||
at the door who would have clicked "sign in with GitHub" without thinking. That's
|
||||
a real cost, especially early in a project when you're trying to grow a contributor
|
||||
base.
|
||||
|
||||
These are honest costs. They're worth it if you care about owning your project —
|
||||
your code, your community, your data, your identity. They're not worth it if you
|
||||
just want the path of least resistance. Know which one you are.
|
||||
|
||||
---
|
||||
|
||||
## The Point
|
||||
|
||||
One domain. One identity. Everything in one place — and it's yours.
|
||||
6
benchmarks/harbor-buzz-orchestra/.gitignore
vendored
6
benchmarks/harbor-buzz-orchestra/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.benchmark/
|
||||
jobs/
|
||||
@@ -1,129 +0,0 @@
|
||||
# Harbor Buzz Orchestra
|
||||
|
||||
A stock-Harbor custom agent that runs a manifest-defined team through the real
|
||||
Buzz stack. Harbor sees one `BuzzOrchestraAgent`; behind that adapter, one
|
||||
orchestrator and N workers coordinate over the production relay/Postgres.
|
||||
Each agent runs *inside* the Harbor task container as the same
|
||||
`buzz-acp` → `buzz-agent` → `buzz-dev-mcp` process tree the desktop app
|
||||
launches: the production MCP toolset (shell, file tools, todo) with the
|
||||
`buzz` CLI on the shell's PATH. No Harbor fork or patch is required.
|
||||
|
||||
## Define the team
|
||||
|
||||
The manifest is the benchmark condition. Each roster entry selects an agent
|
||||
class's count, model endpoint, byte-pinned system prompt, generation settings,
|
||||
and budget:
|
||||
|
||||
```yaml
|
||||
condition: my-team
|
||||
roster:
|
||||
- id: orch
|
||||
kind: orchestrator
|
||||
role: lead
|
||||
count: 1
|
||||
endpoint: databricks/frontier
|
||||
prompt: {path: personas/orchestrator.md, sha256: <sha256>}
|
||||
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
|
||||
- id: worker
|
||||
kind: worker
|
||||
role: implementer
|
||||
count: 4
|
||||
endpoint: databricks/fast-worker
|
||||
prompt: {path: personas/worker.md, sha256: <sha256>}
|
||||
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
|
||||
```
|
||||
|
||||
`endpoint_config` maps those endpoint names to providers, URLs, and API-key
|
||||
environment variables. The adapter contains no fixed roster or model.
|
||||
|
||||
## Run
|
||||
|
||||
With the production compose stack and model endpoints already running, execute
|
||||
one task (`-p`), a directory of tasks, or replace `-p` with Harbor's dataset and
|
||||
task selectors:
|
||||
|
||||
```bash
|
||||
uv run --project benchmarks/harbor-buzz-orchestra/testbed harbor run --yes -p <TASK_OR_DIRECTORY> --agent harbor_buzz_orchestra:BuzzOrchestraAgent --agent-kwarg manifest=<CONDITION.yaml> --agent-kwarg provisioner_factory=harbor_buzz_testbed:provisioner_from_dict --agent-kwarg provisioner_config=<PROVISIONER.json> --agent-kwarg endpoint_config=<ENDPOINTS.json> --agent-kwarg artifact_root=benchmarks/harbor-buzz-orchestra --agent-kwarg buzz_acp_binary=<LINUX_BIN>/buzz-acp --agent-kwarg buzz_agent_binary=<LINUX_BIN>/buzz-agent --agent-kwarg buzz_dev_mcp_binary=<LINUX_BIN>/buzz-dev-mcp --agent-kwarg buzz_cli_binary=target/debug/buzz --agent-kwarg run_id="bench-$(date -u +%Y%m%dT%H%M%SZ)" --agent-timeout-multiplier 15 --n-concurrent 1
|
||||
```
|
||||
|
||||
`buzz_acp_binary`/`buzz_agent_binary`/`buzz_dev_mcp_binary` must be **Linux**
|
||||
builds matching the task image architecture — they are uploaded into each task
|
||||
container (`just benchmark` cross-builds them automatically; musl-static, so
|
||||
any Linux base image works). `buzz_cli_binary` is the **host** CLI the harness
|
||||
uses to act as the trial user.
|
||||
|
||||
`--n-concurrent 1` is the safe laptop setting for a serialized local model; it
|
||||
is not an orchestration requirement. Some TB graders install dependencies from
|
||||
public package registries at verification time — run benchmarks off networks
|
||||
that block those installs (e.g. corporate VPNs).
|
||||
|
||||
Each trial gets fresh keys and a private Buzz channel. The provisioner archives
|
||||
rather than deletes that channel, leaving the relay/Postgres event timeline
|
||||
and the per-agent acp/agent logs (downloaded into the trial's `buzz/`
|
||||
artifacts) available for analysis.
|
||||
|
||||
## Leaderboard runs
|
||||
|
||||
`just benchmark` is the one-command path: it stands up a dedicated Docker
|
||||
stack (`buzz-benchmark` compose project — relay :3600, Postgres :5633, secrets
|
||||
generated once into the gitignored `.benchmark/`), applies the benchmark
|
||||
schema, and defaults to leaderboard-eligible settings (Terminal-Bench 2.1,
|
||||
5 attempts per problem, the Sonnet+Haiku team). All selectors pass through:
|
||||
|
||||
```bash
|
||||
just benchmark # full TB 2.1, k=5
|
||||
just benchmark --path <TASK_DIR> -k 1 # one local task, one attempt
|
||||
just benchmark -i "cobol*" --attempts 3 # dataset subset
|
||||
just benchmark --gui # watch the run live
|
||||
```
|
||||
|
||||
One pinned user identity fronts the whole benchmark environment: it owns
|
||||
every trial channel (named after the task) and posts every task prompt, and
|
||||
trial channels are kept rather than archived. `--gui` adds that user to the
|
||||
relay membership list and opens the Buzz desktop app logged in as them, so
|
||||
channels fill the sidebar as the run progresses — watch, don't type; a human
|
||||
message mid-trial would taint the run. `just benchmark-down` stops the stack.
|
||||
|
||||
Networking: the relay is host-header tenant-bound, so agents must dial its
|
||||
canonical address (`ws://localhost:3600`) even from inside a task container.
|
||||
`just benchmark` uploads a tiny std-only loopback forwarder
|
||||
([`forwarder/relay_forwarder.rs`](forwarder/relay_forwarder.rs)) with the
|
||||
agent stack; it listens on the container's loopback and bridges the byte
|
||||
stream to the Docker host gateway (`host.docker.internal`, overridable via
|
||||
`BUZZ_BENCHMARK_DOCKER_HOST`).
|
||||
|
||||
`scripts/run_leaderboard.py` is the layer underneath, for running against an
|
||||
already-provisioned stack. It wraps the invocation above with only
|
||||
leaderboard-legal settings — it does not accept or forward timeout or resource
|
||||
overrides, so the job directory it produces passes Harbor's static validation
|
||||
as-is. Give it a problem set, attempts per problem, and a team manifest:
|
||||
|
||||
```bash
|
||||
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
|
||||
--dataset terminal-bench/terminal-bench-2-1 \
|
||||
--attempts 5 \
|
||||
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
|
||||
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
|
||||
--provisioner-config <PROVISIONER.json>
|
||||
```
|
||||
|
||||
`--path` replaces `--dataset` for local task directories; `--include-task` /
|
||||
`--exclude-task` filter by glob; `--dry-run` prints the underlying `harbor run`
|
||||
command. After the job finishes the script derives a `metadata.yaml` from the
|
||||
manifest roster (validated schema; review the display names before submitting)
|
||||
and prints the `harbor upload` / `harbor leaderboard submit` commands.
|
||||
|
||||
## Validate
|
||||
|
||||
```bash
|
||||
cd benchmarks/harbor-buzz-orchestra
|
||||
uv run --extra dev pytest -q
|
||||
uv run --extra dev ruff check .
|
||||
cd testbed
|
||||
uv run --extra dev pytest -q
|
||||
uv run --extra dev ruff check .
|
||||
```
|
||||
|
||||
Live provisioner tests require the benchmark compose stack and opt-in
|
||||
environment described in `testbed/tests/test_provisioner_live.py`.
|
||||
@@ -1,73 +0,0 @@
|
||||
//! Loopback TCP forwarder for the benchmark task container.
|
||||
//!
|
||||
//! The benchmark relay is host-header tenant-bound: its community row is the
|
||||
//! authority of its own `RELAY_URL` (e.g. `localhost:3600`), and a request
|
||||
//! presenting any other `Host` fails closed. Agents inside a Harbor task
|
||||
//! container can only reach the host-published relay via the Docker host
|
||||
//! alias (`host.docker.internal`), which would present the wrong `Host`.
|
||||
//!
|
||||
//! So the container runtime uploads this forwarder next to the agent stack:
|
||||
//! agents dial `ws://localhost:<port>` — presenting the exact `Host` the
|
||||
//! community row expects — and the forwarder bridges the byte stream to the
|
||||
//! host gateway. Transparent to everything above TCP (WebSocket, the buzz
|
||||
//! CLI, git-over-HTTP). std-only; compiled with plain `rustc` against the
|
||||
//! musl target, so it runs on any Linux task image.
|
||||
//!
|
||||
//! Usage: `relay-forwarder <listen-addr> <target-addr>`
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (listen, target) = match (args.next(), args.next()) {
|
||||
(Some(listen), Some(target)) => (listen, target),
|
||||
_ => {
|
||||
eprintln!("usage: relay-forwarder <listen-addr> <target-addr>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let listener = TcpListener::bind(&listen)?;
|
||||
// Readiness marker: the container runtime polls the log for this line
|
||||
// before launching any agent.
|
||||
println!("forwarding {listen} -> {target}");
|
||||
for client in listener.incoming() {
|
||||
let Ok(client) = client else { continue };
|
||||
let target = target.clone();
|
||||
thread::spawn(move || bridge(client, &target));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect upstream and pump bytes both ways until either side closes.
|
||||
fn bridge(client: TcpStream, target: &str) {
|
||||
let Ok(upstream) = TcpStream::connect(target) else {
|
||||
let _ = client.shutdown(Shutdown::Both);
|
||||
return;
|
||||
};
|
||||
let (Ok(client_read), Ok(upstream_read)) = (client.try_clone(), upstream.try_clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let downstream = thread::spawn(move || pipe(upstream_read, client));
|
||||
pipe(client_read, upstream);
|
||||
let _ = downstream.join();
|
||||
}
|
||||
|
||||
/// Copy until EOF or error, then half-close the write side so protocols
|
||||
/// layered on TCP (WebSocket close handshakes) terminate cleanly.
|
||||
fn pipe(mut from: TcpStream, mut to: TcpStream) {
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
loop {
|
||||
match from.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if to.write_all(&buf[..n]).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = to.shutdown(Shutdown::Write);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# M1 gate manifest: 1 orchestrator + 1 worker over one Buzz channel solving
|
||||
# Harbor's examples/tasks/hello-world via vanilla `harbor run`.
|
||||
#
|
||||
# Endpoints are LOCAL PLACEHOLDERS for the M1 wiring proof; the pilot/2.1
|
||||
# manifests will carry exact Databricks serving endpoint revisions and the
|
||||
# frozen price table per the canonical plan.
|
||||
schema_version: "1"
|
||||
condition: M1-hello-world
|
||||
roster:
|
||||
- id: orch
|
||||
kind: orchestrator
|
||||
role: lead
|
||||
count: 1
|
||||
endpoint: local/placeholder-orchestrator
|
||||
model_revision: m1-placeholder
|
||||
prompt:
|
||||
path: personas/orchestrator-m1.md
|
||||
sha256: af64b515de914c99fbdfc5a50830909ac10ab3a4f37e8352a354019a6be74151
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 128000
|
||||
- id: worker
|
||||
kind: worker
|
||||
role: implementer
|
||||
count: 1
|
||||
endpoint: local/placeholder-worker
|
||||
model_revision: m1-placeholder
|
||||
prompt:
|
||||
path: personas/worker-m1.md
|
||||
sha256: f3fef3d2c42105c256ff019d71ef99506b31a23e8cb252cac727c8bc470eb304
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 128000
|
||||
prices:
|
||||
local/placeholder-orchestrator:
|
||||
input_per_million_usd: 0
|
||||
cached_input_per_million_usd: 0
|
||||
output_per_million_usd: 0
|
||||
local/placeholder-worker:
|
||||
input_per_million_usd: 0
|
||||
cached_input_per_million_usd: 0
|
||||
output_per_million_usd: 0
|
||||
trial_budget:
|
||||
timeout_seconds: 1800
|
||||
@@ -1,42 +0,0 @@
|
||||
# Terminal-Bench trial: 1 Sonnet 4.6 orchestrator + 2 Haiku 4.5 workers over
|
||||
# one Buzz channel. Endpoint names are exact Anthropic model IDs (the runtime
|
||||
# passes the endpoint name to the provider as the model).
|
||||
# Prices: Anthropic list $/Mtok as of 2026-07 (Sonnet 3/15, Haiku 1/5).
|
||||
schema_version: "1"
|
||||
condition: tb-cobol-sonnet46-2haiku45
|
||||
roster:
|
||||
- id: orch
|
||||
kind: orchestrator
|
||||
role: lead
|
||||
count: 1
|
||||
endpoint: claude-sonnet-4-6
|
||||
model_revision: claude-sonnet-4-6
|
||||
prompt:
|
||||
path: personas/orchestrator-tb.md
|
||||
sha256: 206829331cdd85fb277266901b9489e190ba23097600a8bb69142d044b9a42e6
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 200000
|
||||
- id: worker
|
||||
kind: worker
|
||||
role: implementer
|
||||
count: 2
|
||||
endpoint: claude-haiku-4-5
|
||||
model_revision: claude-haiku-4-5
|
||||
prompt:
|
||||
path: personas/worker-tb.md
|
||||
sha256: 664a994ae3ba93f02ebbb8f61f781f6a59ea3327b21e26e7ffc2d06827ed3e45
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 200000
|
||||
prices:
|
||||
claude-sonnet-4-6:
|
||||
input_per_million_usd: 3
|
||||
cached_input_per_million_usd: 0.3
|
||||
output_per_million_usd: 15
|
||||
claude-haiku-4-5:
|
||||
input_per_million_usd: 1
|
||||
cached_input_per_million_usd: 0.1
|
||||
output_per_million_usd: 5
|
||||
trial_budget:
|
||||
timeout_seconds: 900
|
||||
@@ -1,34 +0,0 @@
|
||||
# Orchestrator — M1 hello-world
|
||||
|
||||
You are the orchestrator of a small team solving a terminal task. You do not
|
||||
run commands yourself; workers do. You coordinate over a Buzz channel.
|
||||
|
||||
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
|
||||
you. Nothing you write is visible to anyone unless you publish it: every
|
||||
message — step assignments, verification requests, the final `DONE:`
|
||||
— must be sent with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your team, your
|
||||
channel id, and the user you report to are listed in the "Your team"
|
||||
section below. Your turn is not complete until you have published your
|
||||
message.
|
||||
|
||||
Rules:
|
||||
1. Read the task instruction. Break it into the smallest concrete steps.
|
||||
2. Assign each step to a worker with an @mention. One step per message.
|
||||
State the exact goal and the success check, not just the command to run.
|
||||
The task runs in the worker's terminal working directory. Unless the task
|
||||
instruction itself names a path, refer to files by bare relative name
|
||||
(`hello.txt`) — never invent an absolute path. More generally, relay the
|
||||
task's requirements verbatim and do not add constraints the task does not
|
||||
state (paths, encodings, byte-level rules such as forbidding a trailing
|
||||
newline). Where the task is silent, let standard tool defaults apply.
|
||||
3. Wait for the worker's report before assigning the next dependent step.
|
||||
4. When a worker reports output, verify it against the task's success
|
||||
criteria yourself before moving on.
|
||||
5. When the task is complete, report back to the user: publish a final
|
||||
message starting with `DONE:` that @mentions the user and summarizes
|
||||
what was produced and how you verified it. The task is not finished
|
||||
until this message is published — never conclude silently.
|
||||
|
||||
Keep messages short. Never fabricate command output. If a worker's report is
|
||||
ambiguous, ask them to re-run with the exact verification command.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Orchestrator — Terminal-Bench team
|
||||
|
||||
You are the orchestrator of a small team solving a terminal task. You do not
|
||||
run commands yourself; your workers do. You coordinate over a Buzz channel.
|
||||
Your team, your channel id, and the user you report to are listed in the
|
||||
"Your team" section below.
|
||||
|
||||
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
|
||||
you. Nothing you write is visible to anyone unless you publish it: every
|
||||
message — step assignments, verification requests, the final `DONE:`
|
||||
report — must be sent with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||
not complete until you have published your message. Do not use the shell
|
||||
for task work — that is your workers' job.
|
||||
|
||||
Tasks arrive as a channel message from the user @mentioning you. Address
|
||||
each assignment to a specific worker by @mention, exactly one worker per
|
||||
step. You may assign independent steps to different workers, but never give
|
||||
two workers overlapping or conflicting work — they share one task
|
||||
environment and one filesystem.
|
||||
|
||||
Rules:
|
||||
1. Read the task instruction. Break it into the smallest concrete steps.
|
||||
2. Assign each step to a worker with an @mention. One step per message.
|
||||
State the exact goal and the success check, not just the command to run.
|
||||
Relay the task's requirements verbatim — use the paths the task states,
|
||||
and do not add constraints the task does not state (paths, encodings,
|
||||
byte-level rules). Where the task is silent, let standard tool defaults
|
||||
apply.
|
||||
3. Wait for the worker's report before assigning the next dependent step.
|
||||
4. When a worker reports output, verify it against the task's success
|
||||
criteria before moving on: assign a verification step that runs the
|
||||
task's own success check and shows real output. Assign each verification
|
||||
step to a different worker than the one whose work is being verified —
|
||||
independent verification, never self-review. Do not report completion on
|
||||
a worker's claim alone.
|
||||
5. When the task is complete and verified, report back to the user: publish
|
||||
a final message starting with `DONE:` that @mentions the user and
|
||||
summarizes what was produced and how it was verified. The task is not
|
||||
finished until this message is published — never conclude silently.
|
||||
|
||||
Keep messages short. Never fabricate command output. If a worker's report is
|
||||
ambiguous, ask them to re-run with the exact verification command.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Worker — M1 hello-world
|
||||
|
||||
You are a worker agent with terminal access, coordinating over a Buzz channel.
|
||||
|
||||
You work directly in the task environment: your `shell` tool runs
|
||||
commands in it, and your file tools read and edit its files. The same
|
||||
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
|
||||
to the channel with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your team and your
|
||||
channel id are listed in the "Your team" section below. Your turn is not
|
||||
complete until you have published your report.
|
||||
|
||||
The orchestrator only wakes for messages that @mention it. Every report
|
||||
you publish must start with an @mention of the agent that assigned you the
|
||||
step (use their name exactly as it appears in their message). If the
|
||||
assignment's event id is visible in your context, also pass
|
||||
`--reply-to <event-id>` to thread the report. A report that mentions
|
||||
nobody is invisible and the task will stall.
|
||||
|
||||
Rules:
|
||||
1. Act only on steps assigned to you by the orchestrator's @mention.
|
||||
2. Execute the requested step in the terminal. Prefer the smallest command
|
||||
that achieves the stated goal. Create files in your terminal's current
|
||||
working directory unless the task itself names a path. If an assignment
|
||||
gives an absolute path your working directory does not contain, report
|
||||
the mismatch instead of creating the directory — `mkdir -p` on an
|
||||
invented path silently puts the file where the grader will never look.
|
||||
3. Report back in one message: the command you ran, its exit code, and the
|
||||
relevant output (trimmed, never invented).
|
||||
4. If a command fails, report the failure verbatim and stop — do not
|
||||
improvise a different approach without the orchestrator's direction.
|
||||
5. Never claim success without showing the verifying output.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Worker — Terminal-Bench team
|
||||
|
||||
You are a worker agent with terminal access, coordinating over a Buzz
|
||||
channel. Your team, your channel id, and your orchestrator are listed in
|
||||
the "Your team" section below.
|
||||
|
||||
You work directly in the task environment: your `shell` tool runs
|
||||
commands in it, and your file tools read and edit its files. The same
|
||||
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
|
||||
to the channel with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||
not complete until you have published your report.
|
||||
|
||||
The orchestrator only wakes for messages that @mention it. Every report
|
||||
you publish must start with an @mention of the agent that assigned you the
|
||||
step (use their name exactly as it appears in their message). If the
|
||||
assignment's event id is visible in your context, also pass
|
||||
`--reply-to <event-id>` to thread the report. A report that mentions
|
||||
nobody is invisible and the task will stall.
|
||||
|
||||
Rules:
|
||||
1. Act only on steps assigned to you by the orchestrator's @mention. If a
|
||||
message assigns a step to a different worker, ignore it.
|
||||
2. Execute the requested step in the terminal BEFORE writing any report.
|
||||
Never describe output you have not yet produced. Prefer the smallest
|
||||
command that achieves the stated goal. Use the paths the task or the
|
||||
assignment states; do not invent paths.
|
||||
3. Report back in one message: the command you ran, its exit code, and the
|
||||
relevant output (trimmed, never invented).
|
||||
4. If a command fails, report the failure verbatim and stop — do not
|
||||
improvise a different approach without the orchestrator's direction.
|
||||
5. Never claim success without showing the verifying output.
|
||||
@@ -1,29 +0,0 @@
|
||||
[project]
|
||||
name = "harbor-buzz-orchestra"
|
||||
version = "0.1.0"
|
||||
description = "Harbor custom agent for Buzz-coordinated multi-agent benchmarks"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"harbor>=0.16.1,<0.18",
|
||||
"pydantic>=2.11",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.4",
|
||||
"pytest-asyncio>=1.2",
|
||||
"ruff>=0.15",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 88
|
||||
@@ -1,550 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-command benchmark: bring up the Buzz stack in Docker and run it.
|
||||
|
||||
``just benchmark`` wraps this script. Defaults are leaderboard-eligible out
|
||||
of the box (Terminal-Bench 2.1, 5 attempts per problem, the Sonnet+Haiku
|
||||
team); every ``run_leaderboard.py`` selector passes through unchanged. The
|
||||
script owns everything around the run:
|
||||
|
||||
- A dedicated ``buzz-benchmark`` compose project reusing the production
|
||||
bundle (``deploy/compose/compose.yml``) plus the benchmark port overlay,
|
||||
on its own ports (relay :3600, Postgres :5633, metrics :9602) so it never
|
||||
collides with a dev stack. Secrets and identities are generated once into
|
||||
the gitignored ``.benchmark/`` state dir and reused across runs.
|
||||
- One pinned *user* identity for the whole benchmark environment: it owns
|
||||
every trial channel and posts every task, like one human running many
|
||||
teams. Channels are kept (not archived) after each trial.
|
||||
- ``--gui`` adds that user to the relay membership list and opens the Buzz
|
||||
desktop app logged in as them, so a human can watch the teams work live.
|
||||
|
||||
Run inside the testbed environment (the just recipe does this):
|
||||
|
||||
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py [--gui] [...]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = PACKAGE_ROOT.parents[1]
|
||||
STATE_DIR = PACKAGE_ROOT / ".benchmark"
|
||||
|
||||
COMPOSE_PROJECT = "buzz-benchmark"
|
||||
COMPOSE_FILES = (
|
||||
REPO_ROOT / "deploy" / "compose" / "compose.yml",
|
||||
PACKAGE_ROOT / "testbed" / "compose.benchmark.yml",
|
||||
)
|
||||
RELAY_HTTP_PORT = 3600
|
||||
PG_HOST_PORT = 5633
|
||||
METRICS_HOST_PORT = 9602
|
||||
GUI_BUNDLE_IDENTIFIER = "xyz.block.buzz.app.benchmark"
|
||||
|
||||
DEFAULT_DATASET = "terminal-bench/terminal-bench-2-1"
|
||||
DEFAULT_ATTEMPTS = 5
|
||||
DEFAULT_MANIFEST = PACKAGE_ROOT / "manifests" / "tb-cobol-sonnet-haiku.yaml"
|
||||
DEFAULT_ENDPOINTS = PACKAGE_ROOT / "testbed" / "endpoints" / "anthropic-live.json"
|
||||
SCHEMA_SQL = PACKAGE_ROOT / "testbed" / "sql" / "benchmark_schema.sql"
|
||||
|
||||
# Linux builds of the production agent stack, uploaded into each task
|
||||
# container per trial. Built once in a rust:alpine container (musl → fully
|
||||
# static, runs on any Linux task image of the same architecture) and cached.
|
||||
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
|
||||
# Std-only loopback forwarder (not a workspace crate): agents dial the
|
||||
# relay's canonical localhost address inside the task container and the
|
||||
# forwarder bridges to the Docker host gateway. Compiled with plain rustc
|
||||
# in the same cross-build step.
|
||||
FORWARDER_SOURCE = PACKAGE_ROOT / "forwarder" / "relay_forwarder.rs"
|
||||
FORWARDER_BINARY = "relay-forwarder"
|
||||
LINUX_TARGET_DIR = STATE_DIR / "linux-target"
|
||||
RUST_IMAGE = "rust:1.95-alpine"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"run_leaderboard", Path(__file__).resolve().parent / "run_leaderboard.py"
|
||||
)
|
||||
run_leaderboard = importlib.util.module_from_spec(_spec)
|
||||
sys.modules.setdefault("run_leaderboard", run_leaderboard)
|
||||
_spec.loader.exec_module(run_leaderboard)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__.splitlines()[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
problems = parser.add_mutually_exclusive_group()
|
||||
problems.add_argument(
|
||||
"--dataset", "-d", default=None,
|
||||
help=f"Registry dataset (default: {DEFAULT_DATASET})",
|
||||
)
|
||||
problems.add_argument(
|
||||
"--path", "-p", type=Path, help="Local task or dataset directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-task", "-i", action="append", default=[],
|
||||
help="Task name to include (glob, repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-task", "-x", action="append", default=[],
|
||||
help="Task name to exclude (glob, repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS,
|
||||
help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest", type=Path, default=DEFAULT_MANIFEST,
|
||||
help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS,
|
||||
help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})",
|
||||
)
|
||||
parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
|
||||
parser.add_argument(
|
||||
"--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root"
|
||||
)
|
||||
parser.add_argument("--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)")
|
||||
parser.add_argument(
|
||||
"--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui", action="store_true",
|
||||
help="Open the Buzz desktop app as the benchmark user to watch the run live",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fresh", action="store_true",
|
||||
help="Reset first: drop the stack's Docker volumes and the benchmark "
|
||||
"GUI's app state (keys in state.json are kept)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Print the underlying harbor command and exit (no stack bring-up)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# -- state: secrets and identities, generated once --------------------------
|
||||
|
||||
|
||||
def load_state() -> dict[str, str]:
|
||||
"""Generate-or-load the benchmark environment's keys and secrets."""
|
||||
from harbor_buzz_testbed.keys import generate_keypair, keypair_from_secret
|
||||
|
||||
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
state_path = STATE_DIR / "state.json"
|
||||
if state_path.is_file():
|
||||
state = json.loads(state_path.read_text())
|
||||
else:
|
||||
owner = generate_keypair()
|
||||
user = generate_keypair()
|
||||
state = {
|
||||
"owner_secret_key": owner.secret_key,
|
||||
"user_secret_key": user.secret_key,
|
||||
"postgres_password": secrets.token_urlsafe(24),
|
||||
"redis_password": secrets.token_urlsafe(24),
|
||||
"typesense_api_key": secrets.token_hex(16),
|
||||
"s3_access_key": secrets.token_hex(10),
|
||||
"s3_secret_key": secrets.token_hex(20),
|
||||
"git_hook_hmac_secret": secrets.token_hex(32),
|
||||
"relay_private_key": generate_keypair().secret_key,
|
||||
}
|
||||
state_path.touch(mode=0o600)
|
||||
state_path.write_text(json.dumps(state, indent=2))
|
||||
state["owner_pubkey"] = keypair_from_secret(state["owner_secret_key"]).pubkey
|
||||
state["user_pubkey"] = keypair_from_secret(state["user_secret_key"]).pubkey
|
||||
return state
|
||||
|
||||
|
||||
def print_user_identity(state: dict[str, str]) -> None:
|
||||
"""Show the pinned benchmark user's key so a human can import it during
|
||||
the desktop GUI's onboarding (this stack is local-only; the key guards
|
||||
nothing beyond it)."""
|
||||
from harbor_buzz_testbed.keys import encode_nsec
|
||||
|
||||
print(
|
||||
f"benchmark user pubkey: {state['user_pubkey']}\n"
|
||||
f"benchmark user nsec: {encode_nsec(state['user_secret_key'])} "
|
||||
"(import this in the GUI onboarding to watch as the benchmark user)"
|
||||
)
|
||||
|
||||
|
||||
def write_env_file(state: dict[str, str]) -> Path:
|
||||
"""Compose interpolation env — regenerated from state on every run."""
|
||||
env_path = STATE_DIR / ".env"
|
||||
lines = {
|
||||
"BUZZ_IMAGE": os.environ.get("BUZZ_IMAGE", "ghcr.io/block/buzz:main"),
|
||||
"BUZZ_DOMAIN": "localhost",
|
||||
"RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||
"BUZZ_MEDIA_BASE_URL": f"http://localhost:{RELAY_HTTP_PORT}/media",
|
||||
"BUZZ_MEDIA_SERVER_DOMAIN": "localhost",
|
||||
"BUZZ_CORS_ORIGINS": f"http://localhost:{RELAY_HTTP_PORT}",
|
||||
"BUZZ_REQUIRE_AUTH_TOKEN": "true",
|
||||
"BUZZ_REQUIRE_RELAY_MEMBERSHIP": "true",
|
||||
"BUZZ_ALLOW_NIP_OA_AUTH": "true",
|
||||
"BUZZ_AUTO_MIGRATE": "true",
|
||||
"BUZZ_GIT_CONFORMANCE_PROBE": "true",
|
||||
"RUST_LOG": "buzz_relay=info,buzz_db=info,buzz_auth=info",
|
||||
"RELAY_OWNER_PUBKEY": state["owner_pubkey"],
|
||||
"BUZZ_RELAY_PRIVATE_KEY": state["relay_private_key"],
|
||||
"BUZZ_GIT_HOOK_HMAC_SECRET": state["git_hook_hmac_secret"],
|
||||
"POSTGRES_DB": "buzz",
|
||||
"POSTGRES_USER": "buzz",
|
||||
"POSTGRES_PASSWORD": state["postgres_password"],
|
||||
"REDIS_PASSWORD": state["redis_password"],
|
||||
"TYPESENSE_API_KEY": state["typesense_api_key"],
|
||||
"BUZZ_S3_ACCESS_KEY": state["s3_access_key"],
|
||||
"BUZZ_S3_SECRET_KEY": state["s3_secret_key"],
|
||||
"BUZZ_S3_BUCKET": "buzz-media",
|
||||
"BUZZ_HTTP_PORT": str(RELAY_HTTP_PORT),
|
||||
"BUZZ_PG_HOST_PORT": str(PG_HOST_PORT),
|
||||
"BUZZ_METRICS_HOST_PORT": str(METRICS_HOST_PORT),
|
||||
}
|
||||
env_path.touch(mode=0o600)
|
||||
env_path.write_text("".join(f"{k}={v}\n" for k, v in lines.items()))
|
||||
return env_path
|
||||
|
||||
|
||||
def postgres_dsn(state: dict[str, str]) -> str:
|
||||
return (
|
||||
f"postgresql://buzz:{state['postgres_password']}"
|
||||
f"@127.0.0.1:{PG_HOST_PORT}/buzz"
|
||||
)
|
||||
|
||||
|
||||
def write_provisioner_config(
|
||||
state: dict[str, str], endpoint_config: Path
|
||||
) -> Path:
|
||||
"""Resolve per-endpoint API keys from the environment and write the
|
||||
provisioner config: pinned user, keep-channels teardown."""
|
||||
endpoints = json.loads(endpoint_config.read_text())
|
||||
llm_api_keys: dict[str, str] = {}
|
||||
for name, entry in endpoints.items():
|
||||
env_var = entry["api_key_env"]
|
||||
key = os.environ.get(env_var)
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
f"endpoint {name!r} needs the {env_var} environment variable"
|
||||
)
|
||||
llm_api_keys[name] = key
|
||||
config = {
|
||||
"relay_http_url": f"http://localhost:{RELAY_HTTP_PORT}",
|
||||
# The agents dial the relay's CANONICAL address — the relay is
|
||||
# host-header tenant-bound, and its community row is the authority
|
||||
# of RELAY_URL (localhost:3600). Inside the task container that
|
||||
# loopback address is served by a tiny forwarder bridging to the
|
||||
# Docker host gateway (see --relay-gateway below).
|
||||
"relay_ws_url": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||
"owner_secret_key": state["owner_secret_key"],
|
||||
"postgres_dsn": postgres_dsn(state),
|
||||
"llm_api_keys": llm_api_keys,
|
||||
"user_secret_key": state["user_secret_key"],
|
||||
"archive_on_teardown": False,
|
||||
}
|
||||
path = STATE_DIR / "provisioner.json"
|
||||
path.touch(mode=0o600)
|
||||
path.write_text(json.dumps(config, indent=2))
|
||||
return path
|
||||
|
||||
|
||||
# -- docker stack ------------------------------------------------------------
|
||||
|
||||
|
||||
def compose_command(*args: str) -> list[str]:
|
||||
command = [
|
||||
"docker", "compose",
|
||||
"--project-name", COMPOSE_PROJECT,
|
||||
"--project-directory", str(STATE_DIR),
|
||||
"--env-file", str(STATE_DIR / ".env"),
|
||||
]
|
||||
for file in COMPOSE_FILES:
|
||||
command += ["-f", str(file)]
|
||||
return command + list(args)
|
||||
|
||||
|
||||
def stale_credential_volume(state: dict[str, str]) -> bool:
|
||||
"""True when Postgres is up but rejects THIS clone's password — the
|
||||
volume was initialized by another checkout's ``.benchmark/`` state
|
||||
(compose project name is machine-global, state dir is per-clone)."""
|
||||
import psycopg
|
||||
|
||||
try:
|
||||
psycopg.connect(postgres_dsn(state), connect_timeout=5).close()
|
||||
except psycopg.OperationalError as error:
|
||||
return "password authentication failed" in str(error)
|
||||
return False
|
||||
|
||||
|
||||
def bring_up_stack(state: dict[str, str]) -> None:
|
||||
"""Compose bring-up (idempotent), self-healing the one known-fatal
|
||||
failure: a stale Postgres volume from a different clone. Nothing in
|
||||
that volume is usable (we can't even authenticate to it), so drop the
|
||||
volumes and retry once rather than aborting with instructions."""
|
||||
try:
|
||||
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
if not stale_credential_volume(state):
|
||||
raise
|
||||
print(
|
||||
"benchmark Postgres volume was initialized by a different "
|
||||
"checkout's .benchmark/ state — dropping the stale volumes and "
|
||||
"retrying..."
|
||||
)
|
||||
subprocess.run(compose_command("down", "-v"), check=True)
|
||||
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
|
||||
|
||||
|
||||
def reset_environment() -> None:
|
||||
"""--fresh: drop the stack's Docker volumes and the benchmark GUI's
|
||||
app state, together — GUI records (workspaces, read state) only stay
|
||||
coherent as long as the database they reference exists. Keys in
|
||||
``state.json`` are kept, so the same nsec works after the reset."""
|
||||
subprocess.run(compose_command("down", "-v"), check=True)
|
||||
if sys.platform == "darwin":
|
||||
for domain in ("WebKit", "Caches", "Application Support"):
|
||||
shutil.rmtree(
|
||||
Path.home() / "Library" / domain / GUI_BUNDLE_IDENTIFIER,
|
||||
ignore_errors=True,
|
||||
)
|
||||
|
||||
|
||||
def ensure_stack(state: dict[str, str]) -> None:
|
||||
"""Bring the compose stack up (idempotent) and apply the benchmark schema."""
|
||||
import psycopg
|
||||
|
||||
bring_up_stack(state)
|
||||
|
||||
deadline = time.monotonic() + 60
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with psycopg.connect(postgres_dsn(state)) as conn:
|
||||
conn.execute(SCHEMA_SQL.read_text())
|
||||
conn.commit()
|
||||
return
|
||||
except psycopg.Error as error: # containers healthy but PG settling
|
||||
last_error = error
|
||||
time.sleep(2)
|
||||
raise SystemExit(f"benchmark schema apply failed: {last_error}")
|
||||
|
||||
|
||||
# -- buzz binaries -----------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_binaries() -> dict[str, Path]:
|
||||
"""Find the host buzz CLI, building it once if missing."""
|
||||
try:
|
||||
return run_leaderboard.find_binaries(None)
|
||||
except SystemExit:
|
||||
print("host buzz CLI missing — building (cargo build, first run only)...")
|
||||
cargo = REPO_ROOT / "bin" / "cargo"
|
||||
subprocess.run(
|
||||
[str(cargo), "build", "-p", "buzz-cli"],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
)
|
||||
return run_leaderboard.find_binaries(None)
|
||||
|
||||
|
||||
def linux_triple() -> str:
|
||||
"""The musl triple matching the Docker engine that runs task containers."""
|
||||
arch = subprocess.run(
|
||||
["docker", "version", "--format", "{{.Server.Arch}}"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
try:
|
||||
return {
|
||||
"arm64": "aarch64-unknown-linux-musl",
|
||||
"amd64": "x86_64-unknown-linux-musl",
|
||||
}[arch]
|
||||
except KeyError:
|
||||
raise SystemExit(f"unsupported Docker architecture: {arch!r}") from None
|
||||
|
||||
|
||||
def ensure_agent_binaries() -> Path:
|
||||
"""Cross-build the static Linux agent stack once, cached in .benchmark/.
|
||||
|
||||
The agents run *inside* each Harbor task container as the real
|
||||
buzz-acp → buzz-agent → buzz-dev-mcp stack, so the binaries must be
|
||||
Linux ELF for the task image architecture. musl-static means they run
|
||||
on any Linux base image (glibc or not). The relay loopback forwarder
|
||||
is compiled in the same step with plain rustc (std-only, no deps).
|
||||
"""
|
||||
triple = linux_triple()
|
||||
bin_dir = LINUX_TARGET_DIR / triple / "release"
|
||||
targets = AGENT_BINARIES + (FORWARDER_BINARY,)
|
||||
if all((bin_dir / name).is_file() for name in targets):
|
||||
return bin_dir
|
||||
print(f"Linux agent binaries missing — cross-building for {triple} "
|
||||
f"in {RUST_IMAGE} (first run only, ~2 min)...")
|
||||
LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(STATE_DIR / "cargo-registry").mkdir(exist_ok=True)
|
||||
packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)]
|
||||
forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT)
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{REPO_ROOT}:/src:ro",
|
||||
"-v", f"{LINUX_TARGET_DIR}:/target",
|
||||
"-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry",
|
||||
"-e", "CARGO_TARGET_DIR=/target",
|
||||
"-w", "/src",
|
||||
RUST_IMAGE,
|
||||
"sh", "-c",
|
||||
"apk add --no-cache musl-dev >/dev/null && "
|
||||
f"cargo build --release --locked --target {triple} "
|
||||
+ " ".join(packages)
|
||||
+ f" && rustc --edition 2021 -O --target {triple}"
|
||||
f" -o /target/{triple}/release/{FORWARDER_BINARY}"
|
||||
f" /src/{forwarder_src}",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
missing = [n for n in targets if not (bin_dir / n).is_file()]
|
||||
if missing:
|
||||
raise SystemExit(f"cross-build produced no {', '.join(missing)} in {bin_dir}")
|
||||
return bin_dir
|
||||
|
||||
|
||||
# -- GUI ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def launch_gui(state: dict[str, str]) -> subprocess.Popen:
|
||||
"""Open the Buzz desktop app logged in as the benchmark user.
|
||||
|
||||
The relay runs closed (membership required), so the user pubkey is first
|
||||
added to the relay membership list via buzz-admin inside the container —
|
||||
NIP-OA auth tags cover the agents, but the GUI authenticates as a plain
|
||||
member, exactly like a human.
|
||||
"""
|
||||
subprocess.run(
|
||||
compose_command(
|
||||
"exec", "-T", "relay",
|
||||
"buzz-admin", "add-member", "--pubkey", state["user_pubkey"],
|
||||
),
|
||||
check=True,
|
||||
)
|
||||
|
||||
desktop_dir = REPO_ROOT / "desktop"
|
||||
if not (desktop_dir / "node_modules").is_dir():
|
||||
subprocess.run(["pnpm", "install"], cwd=desktop_dir, check=True)
|
||||
|
||||
# tauri dev needs sidecar files present; stub them and drop in the real
|
||||
# CLI binary (mirrors the just staging recipe).
|
||||
target = subprocess.run(
|
||||
["rustc", "-vV"], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
triple = next(
|
||||
line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ")
|
||||
)
|
||||
sidecar_dir = desktop_dir / "src-tauri" / "binaries"
|
||||
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||
binaries = ensure_binaries()
|
||||
for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"):
|
||||
stub = sidecar_dir / f"{name}-{triple}"
|
||||
if not stub.exists():
|
||||
stub.touch()
|
||||
real_cli = sidecar_dir / f"buzz-{triple}"
|
||||
real_cli.write_bytes(binaries["buzz"].read_bytes())
|
||||
real_cli.chmod(0o755)
|
||||
|
||||
print(
|
||||
f"Opening Buzz GUI as the benchmark user ({state['user_pubkey'][:16]}…).\n"
|
||||
"Watch, don't type — a message from you mid-trial would taint the run."
|
||||
)
|
||||
# Distinct bundle identifier: the desktop app persists workspaces (incl.
|
||||
# their relay URLs) in per-identifier WebKit localStorage, and a stored
|
||||
# workspace's relay URL overrides BUZZ_RELAY_URL by design. Reusing the
|
||||
# default identifier means any past local-dev session's ws://localhost:3000
|
||||
# workspace silently shadows the benchmark relay. An identifier of our own
|
||||
# keeps that state isolated both ways.
|
||||
tauri_config = json.dumps(
|
||||
{"identifier": GUI_BUNDLE_IDENTIFIER, "productName": "Buzz Benchmark"}
|
||||
)
|
||||
return subprocess.Popen(
|
||||
["pnpm", "exec", "tauri", "dev", "--config", tauri_config],
|
||||
cwd=desktop_dir,
|
||||
env={
|
||||
**os.environ,
|
||||
"BUZZ_RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||
"BUZZ_PRIVATE_KEY": state["user_secret_key"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# -- main ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def leaderboard_argv(
|
||||
args: argparse.Namespace, provisioner_config: Path, agent_bin_dir: Path
|
||||
) -> list[str]:
|
||||
argv: list[str] = []
|
||||
if args.path:
|
||||
argv += ["--path", str(args.path)]
|
||||
else:
|
||||
argv += ["--dataset", args.dataset or DEFAULT_DATASET]
|
||||
for pattern in args.include_task:
|
||||
argv += ["--include-task", pattern]
|
||||
for pattern in args.exclude_task:
|
||||
argv += ["--exclude-task", pattern]
|
||||
argv += [
|
||||
"--attempts", str(args.attempts),
|
||||
"--manifest", str(args.manifest),
|
||||
"--endpoint-config", str(args.endpoint_config),
|
||||
"--provisioner-config", str(provisioner_config),
|
||||
"--agent-bin-dir", str(agent_bin_dir),
|
||||
# The relay as reachable from inside a task container: Docker's
|
||||
# host alias, bridged to the canonical localhost address by the
|
||||
# uploaded forwarder. Override the alias with
|
||||
# BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host
|
||||
# differently.
|
||||
"--relay-gateway",
|
||||
f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}"
|
||||
f":{RELAY_HTTP_PORT}",
|
||||
"--n-concurrent", str(args.n_concurrent),
|
||||
"--jobs-dir", str(args.jobs_dir),
|
||||
]
|
||||
if args.job_name:
|
||||
argv += ["--job-name", args.job_name]
|
||||
if args.upload:
|
||||
argv.append("--upload")
|
||||
if args.dry_run:
|
||||
argv.append("--dry-run")
|
||||
return argv
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
state = load_state()
|
||||
print_user_identity(state)
|
||||
write_env_file(state)
|
||||
provisioner_config = write_provisioner_config(state, args.endpoint_config)
|
||||
|
||||
if args.dry_run:
|
||||
agent_bin_dir = LINUX_TARGET_DIR / linux_triple() / "release"
|
||||
else:
|
||||
ensure_binaries()
|
||||
agent_bin_dir = ensure_agent_binaries()
|
||||
if args.fresh:
|
||||
reset_environment()
|
||||
ensure_stack(state)
|
||||
if args.gui:
|
||||
launch_gui(state)
|
||||
|
||||
return run_leaderboard.main(
|
||||
leaderboard_argv(args, provisioner_config, agent_bin_dir)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a problem set with a team manifest and produce leaderboard-ready results.
|
||||
|
||||
One command wraps ``harbor run`` with only leaderboard-legal settings — no
|
||||
timeout or resource overrides are accepted or forwarded, so the resulting job
|
||||
directory passes Harbor's static validation as produced. After the run it
|
||||
writes a ``metadata.yaml`` template derived from the manifest and prints the
|
||||
exact upload/submit commands.
|
||||
|
||||
Run inside the testbed environment so ``harbor`` and the adapter are
|
||||
importable:
|
||||
|
||||
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
|
||||
--dataset terminal-bench/terminal-bench-2-1 \
|
||||
--attempts 5 \
|
||||
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
|
||||
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
|
||||
--provisioner-config <PROVISIONER.json> \
|
||||
--agent-bin-dir <DIR with Linux buzz-acp/buzz-agent/buzz-dev-mcp>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||
AGENT_IMPORT = "harbor_buzz_orchestra:BuzzOrchestraAgent"
|
||||
PROVISIONER_FACTORY = "harbor_buzz_testbed:provisioner_from_dict"
|
||||
# Host-side: the harness speaks to the relay as the trial user via this CLI.
|
||||
BINARIES = ("buzz",)
|
||||
# Container-side: the production stack uploaded into each task container.
|
||||
# These must be Linux builds matching the task image architecture.
|
||||
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
|
||||
# Uploaded alongside the stack when --relay-gateway is set: bridges the
|
||||
# agents' canonical relay address to the host gateway (the relay is
|
||||
# host-header tenant-bound, so agents must present its canonical Host).
|
||||
FORWARDER_BINARY = "relay-forwarder"
|
||||
|
||||
PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"}
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
problems = parser.add_mutually_exclusive_group(required=True)
|
||||
problems.add_argument(
|
||||
"--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)"
|
||||
)
|
||||
problems.add_argument(
|
||||
"--path", "-p", type=Path, help="Local task or dataset directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-task", "-i", action="append", default=[],
|
||||
help="Task name to include from the dataset (glob, repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-task", "-x", action="append", default=[],
|
||||
help="Task name to exclude from the dataset (glob, repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--attempts", "-k", type=int, required=True,
|
||||
help="Runs per problem (leaderboards require 5)",
|
||||
)
|
||||
parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML")
|
||||
parser.add_argument(
|
||||
"--endpoint-config", type=Path, required=True,
|
||||
help="JSON mapping manifest endpoint names to providers/API keys",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provisioner-config", type=Path, required=True,
|
||||
help="JSON config for the Buzz relay/Postgres provisioner",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--buzz-bin-dir", type=Path, default=None,
|
||||
help="Directory with the host buzz CLI (default: repo target/release, then target/debug)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agent-bin-dir", type=Path, required=True,
|
||||
help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp "
|
||||
"to upload into each task container",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--relay-gateway", default="",
|
||||
help="host:port of the benchmark relay as reachable from inside the "
|
||||
"task container (e.g. host.docker.internal:3600). When set, a "
|
||||
"loopback forwarder from --agent-bin-dir bridges the canonical "
|
||||
"relay address to this gateway",
|
||||
)
|
||||
parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
|
||||
parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root")
|
||||
parser.add_argument("--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)")
|
||||
parser.add_argument(
|
||||
"--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def find_binaries(bin_dir: Path | None) -> dict[str, Path]:
|
||||
candidates = (
|
||||
[bin_dir]
|
||||
if bin_dir is not None
|
||||
else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")]
|
||||
)
|
||||
for candidate in candidates:
|
||||
found = {name: candidate / name for name in BINARIES}
|
||||
if all(path.is_file() for path in found.values()):
|
||||
return found
|
||||
searched = ", ".join(str(c) for c in candidates)
|
||||
raise SystemExit(
|
||||
f"buzz binaries not found (need {', '.join(BINARIES)}; searched {searched}). "
|
||||
"Build them with `cargo build` or pass --buzz-bin-dir."
|
||||
)
|
||||
|
||||
|
||||
def find_agent_binaries(bin_dir: Path, with_forwarder: bool = False) -> dict[str, Path]:
|
||||
"""The Linux agent stack uploaded into each task container."""
|
||||
names = AGENT_BINARIES + ((FORWARDER_BINARY,) if with_forwarder else ())
|
||||
found = {name: bin_dir / name for name in names}
|
||||
missing = [name for name, path in found.items() if not path.is_file()]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
f"Linux agent binaries not found in {bin_dir}: {', '.join(missing)}. "
|
||||
"`just benchmark` builds them; or cross-compile with "
|
||||
"cargo --target <arch>-unknown-linux-musl and pass --agent-bin-dir."
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def build_command(
|
||||
args: argparse.Namespace,
|
||||
binaries: dict[str, Path],
|
||||
agent_binaries: dict[str, Path],
|
||||
) -> list[str]:
|
||||
"""Compose the harbor invocation. Standard settings only: any timeout or
|
||||
resource override would fail leaderboard static validation, so none are
|
||||
accepted or forwarded."""
|
||||
command = [
|
||||
"harbor", "run", "--yes",
|
||||
"--job-name", args.job_name,
|
||||
"--jobs-dir", str(args.jobs_dir),
|
||||
"-k", str(args.attempts),
|
||||
"--n-concurrent", str(args.n_concurrent),
|
||||
]
|
||||
if args.dataset:
|
||||
command += ["--dataset", args.dataset]
|
||||
else:
|
||||
command += ["--path", str(args.path)]
|
||||
for pattern in args.include_task:
|
||||
command += ["--include-task-name", pattern]
|
||||
for pattern in args.exclude_task:
|
||||
command += ["--exclude-task-name", pattern]
|
||||
if args.upload:
|
||||
command.append("--upload")
|
||||
command += ["--agent", AGENT_IMPORT]
|
||||
kwargs = {
|
||||
"manifest": args.manifest,
|
||||
"provisioner_factory": PROVISIONER_FACTORY,
|
||||
"provisioner_config": args.provisioner_config,
|
||||
"artifact_root": PACKAGE_ROOT,
|
||||
"endpoint_config": args.endpoint_config,
|
||||
"buzz_acp_binary": agent_binaries["buzz-acp"],
|
||||
"buzz_agent_binary": agent_binaries["buzz-agent"],
|
||||
"buzz_dev_mcp_binary": agent_binaries["buzz-dev-mcp"],
|
||||
"buzz_cli_binary": binaries["buzz"],
|
||||
"run_id": args.job_name,
|
||||
}
|
||||
if args.relay_gateway:
|
||||
kwargs["relay_gateway"] = args.relay_gateway
|
||||
kwargs["forwarder_binary"] = agent_binaries[FORWARDER_BINARY]
|
||||
for key, value in kwargs.items():
|
||||
command += ["--agent-kwarg", f"{key}={value}"]
|
||||
return command
|
||||
|
||||
|
||||
def write_metadata_template(args: argparse.Namespace, job_dir: Path) -> Path:
|
||||
"""Derive a metadata.yaml template (harbor.leaderboard.metadata schema)
|
||||
from the manifest roster; display-name placeholders are for the submitter
|
||||
to confirm."""
|
||||
manifest = yaml.safe_load(args.manifest.read_text())
|
||||
endpoints = json.loads(args.endpoint_config.read_text())
|
||||
models, seen = [], set()
|
||||
for entry in manifest.get("roster", []):
|
||||
model = entry.get("model_revision") or entry["endpoint"]
|
||||
if model in seen:
|
||||
continue
|
||||
seen.add(model)
|
||||
provider = endpoints.get(entry["endpoint"], {}).get("provider", "FILL_ME")
|
||||
models.append(
|
||||
{
|
||||
"model_name": model,
|
||||
"model_provider": provider,
|
||||
"model_display_name": model,
|
||||
"model_org_display_name": PROVIDER_ORGS.get(provider, "FILL_ME"),
|
||||
}
|
||||
)
|
||||
metadata = {
|
||||
"agent_url": "https://github.com/block/buzz",
|
||||
"agent_display_name": f"Buzz Orchestra ({manifest.get('condition', 'team')})",
|
||||
"agent_org_display_name": "Block",
|
||||
"models": models,
|
||||
}
|
||||
path = job_dir / "metadata.yaml"
|
||||
path.write_text(yaml.safe_dump(metadata, sort_keys=False))
|
||||
return path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
for label, path in (
|
||||
("manifest", args.manifest),
|
||||
("endpoint config", args.endpoint_config),
|
||||
("provisioner config", args.provisioner_config),
|
||||
):
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"{label} not found: {path}")
|
||||
if args.job_name is None:
|
||||
condition = yaml.safe_load(args.manifest.read_text()).get("condition", "team")
|
||||
stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
args.job_name = f"lb-{condition}-{stamp}"
|
||||
|
||||
if args.dry_run:
|
||||
# Dry runs print the command without requiring built binaries.
|
||||
bin_dir = args.buzz_bin_dir or PACKAGE_ROOT.parents[1] / "target" / "release"
|
||||
binaries = {name: bin_dir / name for name in BINARIES}
|
||||
agent_binaries = {
|
||||
name: args.agent_bin_dir / name
|
||||
for name in AGENT_BINARIES + (FORWARDER_BINARY,)
|
||||
}
|
||||
print(" ".join(build_command(args, binaries, agent_binaries)))
|
||||
return 0
|
||||
binaries = find_binaries(args.buzz_bin_dir)
|
||||
agent_binaries = find_agent_binaries(
|
||||
args.agent_bin_dir, with_forwarder=bool(args.relay_gateway)
|
||||
)
|
||||
command = build_command(args, binaries, agent_binaries)
|
||||
if shutil.which("harbor") is None:
|
||||
raise SystemExit(
|
||||
"harbor not on PATH — run via: uv run --project "
|
||||
f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..."
|
||||
)
|
||||
|
||||
result = subprocess.run(command)
|
||||
job_dir = args.jobs_dir / args.job_name
|
||||
if result.returncode != 0:
|
||||
print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}")
|
||||
return result.returncode
|
||||
|
||||
metadata_path = write_metadata_template(args, job_dir)
|
||||
print("\nLeaderboard-ready job complete.")
|
||||
print(f" 1. Review submitter details in {metadata_path}")
|
||||
print(f" 2. harbor upload {job_dir}")
|
||||
print(
|
||||
" 3. harbor leaderboard submit -l terminal-bench/terminal-bench-2-1 "
|
||||
f"-j <job UUID from upload> -m {metadata_path}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Buzz orchestra custom agent for Harbor."""
|
||||
|
||||
from .agent import BuzzOrchestraAgent
|
||||
from .manifest import ExperimentManifest, ManifestError
|
||||
from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
|
||||
from .runtime import OrchestraRuntime, RuntimeResult
|
||||
from .container_runtime import (
|
||||
BuzzContainerRuntime,
|
||||
EndpointLaunchConfig,
|
||||
RuntimeLaunchError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentCredential",
|
||||
"BuzzOrchestraAgent",
|
||||
"BuzzContainerRuntime",
|
||||
"EndpointLaunchConfig",
|
||||
"ExperimentManifest",
|
||||
"ManifestError",
|
||||
"OrchestraRuntime",
|
||||
"RuntimeResult",
|
||||
"RuntimeLaunchError",
|
||||
"TrialHandle",
|
||||
"TrialProvisioner",
|
||||
]
|
||||
@@ -1,200 +0,0 @@
|
||||
"""Harbor custom-agent entry point for Buzz orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from harbor.agents.base import BaseAgent
|
||||
from harbor.environments.base import BaseEnvironment
|
||||
from harbor.models.agent.context import AgentContext
|
||||
|
||||
from .manifest import ExperimentManifest
|
||||
from .provisioning import TrialProvisioner
|
||||
from .runtime import OrchestraRuntime
|
||||
from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
|
||||
|
||||
|
||||
class BuzzOrchestraAgent(BaseAgent):
|
||||
"""Coordinate an arbitrary manifest-defined team through a Buzz trial."""
|
||||
|
||||
# Set True only once the runtime writes a validated agent/trajectory.json.
|
||||
SUPPORTS_ATIF = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logs_dir: Path,
|
||||
model_name: str | None = None,
|
||||
*,
|
||||
manifest: str | Path | dict[str, Any],
|
||||
provisioner: TrialProvisioner | None = None,
|
||||
runtime: OrchestraRuntime | None = None,
|
||||
provisioner_factory: str | None = None,
|
||||
provisioner_config: str | Path | dict[str, Any] | None = None,
|
||||
artifact_root: str | Path | None = None,
|
||||
endpoint_config: str | Path | dict[str, Any] | None = None,
|
||||
buzz_acp_binary: str = "buzz-acp",
|
||||
buzz_agent_binary: str = "buzz-agent",
|
||||
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
|
||||
buzz_cli_binary: str = "buzz",
|
||||
relay_gateway: str = "",
|
||||
forwarder_binary: str = "relay-forwarder",
|
||||
run_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(logs_dir=logs_dir, model_name=model_name, **kwargs)
|
||||
self.manifest = ExperimentManifest.load(manifest)
|
||||
self.provisioner = provisioner or self._build_provisioner(
|
||||
provisioner_factory, provisioner_config
|
||||
)
|
||||
self.runtime = runtime or self._build_runtime(
|
||||
logs_dir,
|
||||
artifact_root,
|
||||
endpoint_config,
|
||||
buzz_acp_binary,
|
||||
buzz_agent_binary,
|
||||
buzz_dev_mcp_binary,
|
||||
buzz_cli_binary,
|
||||
relay_gateway,
|
||||
forwarder_binary,
|
||||
)
|
||||
self.run_id = run_id
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "buzz-orchestra"
|
||||
|
||||
def version(self) -> str:
|
||||
return "0.1.0"
|
||||
|
||||
@staticmethod
|
||||
def _load_mapping(
|
||||
source: str | Path | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if source is None:
|
||||
return None
|
||||
if isinstance(source, dict):
|
||||
return source
|
||||
import json
|
||||
|
||||
path = Path(source).expanduser()
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot load JSON config {path}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON config {path} must contain an object")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _build_provisioner(
|
||||
cls,
|
||||
factory_path: str | None,
|
||||
config_source: str | Path | dict[str, Any] | None,
|
||||
) -> TrialProvisioner | None:
|
||||
config = cls._load_mapping(config_source)
|
||||
if factory_path is None and config is None:
|
||||
return None
|
||||
if factory_path is None or config is None:
|
||||
raise ValueError(
|
||||
"provisioner_factory and provisioner_config must be provided together"
|
||||
)
|
||||
from harbor.utils.import_path import import_symbol
|
||||
|
||||
factory = import_symbol(factory_path)
|
||||
return factory(config)
|
||||
|
||||
@classmethod
|
||||
def _build_runtime(
|
||||
cls,
|
||||
logs_dir: Path,
|
||||
artifact_root: str | Path | None,
|
||||
endpoint_source: str | Path | dict[str, Any] | None,
|
||||
buzz_acp_binary: str,
|
||||
buzz_agent_binary: str,
|
||||
buzz_dev_mcp_binary: str,
|
||||
buzz_cli_binary: str,
|
||||
relay_gateway: str,
|
||||
forwarder_binary: str,
|
||||
) -> OrchestraRuntime | None:
|
||||
endpoint_data = cls._load_mapping(endpoint_source)
|
||||
if endpoint_data is None and artifact_root is None:
|
||||
return None
|
||||
if endpoint_data is None or artifact_root is None:
|
||||
raise ValueError(
|
||||
"artifact_root and endpoint_config must be provided together"
|
||||
)
|
||||
endpoints = {
|
||||
name: EndpointLaunchConfig(
|
||||
provider=value["provider"],
|
||||
api_key_env=value["api_key_env"],
|
||||
env=value.get("env", {}),
|
||||
)
|
||||
for name, value in endpoint_data.items()
|
||||
}
|
||||
return BuzzContainerRuntime(
|
||||
logs_dir=logs_dir,
|
||||
artifact_root=Path(artifact_root),
|
||||
endpoints=endpoints,
|
||||
buzz_acp_binary=buzz_acp_binary,
|
||||
buzz_agent_binary=buzz_agent_binary,
|
||||
buzz_dev_mcp_binary=buzz_dev_mcp_binary,
|
||||
buzz_cli_binary=buzz_cli_binary,
|
||||
relay_gateway=relay_gateway,
|
||||
forwarder_binary=forwarder_binary,
|
||||
)
|
||||
|
||||
async def setup(self, environment: BaseEnvironment) -> None:
|
||||
"""Fail fast when the provisioner is configured but its stack is unhealthy."""
|
||||
if self.provisioner is not None:
|
||||
self.provisioner.healthcheck()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
instruction: str,
|
||||
environment: BaseEnvironment,
|
||||
context: AgentContext,
|
||||
) -> None:
|
||||
if self.provisioner is None or self.runtime is None:
|
||||
raise RuntimeError(
|
||||
"BuzzOrchestraAgent requires provisioner and runtime integrations; "
|
||||
"the adapter contract is installed but M1 wiring is incomplete"
|
||||
)
|
||||
|
||||
context_id = self.context_id or environment.context_id
|
||||
if context_id is None:
|
||||
raise RuntimeError("Harbor context_id is required as the trial join key")
|
||||
trial_id = str(context_id)
|
||||
run_id = self.run_id or trial_id
|
||||
# Human-readable channel label: the task short name, so a spectator
|
||||
# GUI shows one recognisable channel per problem per attempt.
|
||||
channel_label = getattr(environment, "environment_name", None)
|
||||
handle = self.provisioner.create_trial(
|
||||
run_id, trial_id, self.manifest, channel_label=channel_label
|
||||
)
|
||||
if handle.trial_id != trial_id:
|
||||
raise RuntimeError("provisioner returned a handle for a different trial_id")
|
||||
if handle.manifest_hash != self.manifest.sha256:
|
||||
raise RuntimeError("provisioner returned a handle for a different manifest")
|
||||
try:
|
||||
result = await self.runtime.run(
|
||||
instruction=instruction,
|
||||
environment=environment,
|
||||
manifest=self.manifest,
|
||||
trial=handle,
|
||||
)
|
||||
finally:
|
||||
self.provisioner.teardown(handle)
|
||||
|
||||
context.n_input_tokens = result.input_tokens
|
||||
context.n_cache_tokens = result.cached_input_tokens
|
||||
context.n_output_tokens = result.output_tokens
|
||||
context.cost_usd = result.cost_usd
|
||||
context.metadata = {
|
||||
**result.metadata,
|
||||
"manifest_sha256": self.manifest.sha256,
|
||||
"condition": self.manifest.condition,
|
||||
"buzz_channel_id": handle.channel_id,
|
||||
"run_id": run_id,
|
||||
"trial_id": trial_id,
|
||||
}
|
||||
@@ -1,651 +0,0 @@
|
||||
"""Run the production Buzz agent stack inside the Harbor task container.
|
||||
|
||||
Each provisioned identity is a full ``buzz-acp`` → ``buzz-agent`` →
|
||||
``buzz-dev-mcp`` process tree launched *inside* the task container — the same
|
||||
binaries and the same MCP toolset (shell, file tools, the ``buzz`` CLI on
|
||||
PATH) that the desktop app gives a Buzz agent. The harness stays outside:
|
||||
it provisions, uploads the pinned binaries, posts the task as the trial
|
||||
user, and observes the channel until the orchestrator publishes DONE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from harbor.environments.base import BaseEnvironment
|
||||
|
||||
from .manifest import AgentClass, ExperimentManifest
|
||||
from .provisioning import AgentCredential, TrialHandle
|
||||
from .runtime import RuntimeResult
|
||||
|
||||
|
||||
DEFAULT_MAX_AGENT_ROUNDS = 32
|
||||
# Container-side layout for the uploaded Buzz stack.
|
||||
REMOTE_ROOT = "/opt/buzz"
|
||||
REMOTE_BIN = f"{REMOTE_ROOT}/bin"
|
||||
REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts"
|
||||
REMOTE_LOGS = f"{REMOTE_ROOT}/logs"
|
||||
# The relay is host-header tenant-bound (its community row is the authority
|
||||
# of its own RELAY_URL), so agents must present that exact Host. When the
|
||||
# relay actually lives outside the container, this forwarder listens on the
|
||||
# canonical loopback address and bridges the byte stream to the gateway.
|
||||
FORWARDER = f"{REMOTE_BIN}/relay-forwarder"
|
||||
FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log"
|
||||
# How many done-poll iterations between in-container liveness probes.
|
||||
LIVENESS_EVERY = 10
|
||||
|
||||
|
||||
class RuntimeLaunchError(RuntimeError):
|
||||
"""Raised when a Buzz agent process cannot be launched or exits early."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndpointLaunchConfig:
|
||||
"""Deployment-specific environment needed to launch one manifest endpoint."""
|
||||
|
||||
provider: str
|
||||
api_key_env: str
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Agent:
|
||||
credential: AgentCredential
|
||||
pid: int
|
||||
stdout_log: str # container path
|
||||
stderr_log: str # container path
|
||||
|
||||
|
||||
class BuzzContainerRuntime:
|
||||
"""Launch one production Buzz agent stack per identity in the container."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
logs_dir: Path,
|
||||
artifact_root: Path,
|
||||
endpoints: dict[str, EndpointLaunchConfig],
|
||||
buzz_acp_binary: str = "buzz-acp",
|
||||
buzz_agent_binary: str = "buzz-agent",
|
||||
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
|
||||
buzz_cli_binary: str = "buzz",
|
||||
relay_gateway: str = "",
|
||||
forwarder_binary: str = "relay-forwarder",
|
||||
max_agent_rounds: int = DEFAULT_MAX_AGENT_ROUNDS,
|
||||
readiness_timeout_seconds: float = 60.0,
|
||||
poll_seconds: float = 1.0,
|
||||
) -> None:
|
||||
if max_agent_rounds <= 0:
|
||||
raise ValueError("max_agent_rounds must be positive")
|
||||
if readiness_timeout_seconds <= 0:
|
||||
raise ValueError("readiness_timeout_seconds must be positive")
|
||||
self.logs_dir = Path(logs_dir)
|
||||
self.artifact_root = Path(artifact_root)
|
||||
self.endpoints = endpoints
|
||||
# Linux builds uploaded into the task container:
|
||||
self.buzz_acp_binary = buzz_acp_binary
|
||||
self.buzz_agent_binary = buzz_agent_binary
|
||||
self.buzz_dev_mcp_binary = buzz_dev_mcp_binary
|
||||
# Host build used for user/provisioning operations only:
|
||||
self.buzz_cli_binary = buzz_cli_binary
|
||||
# Where the relay actually lives, as seen from inside the task
|
||||
# container (e.g. host.docker.internal:3600). When set, a loopback
|
||||
# forwarder bridges the agents' canonical relay address — the Host
|
||||
# the relay's community row is bound to — to this gateway.
|
||||
self.relay_gateway = relay_gateway
|
||||
self.forwarder_binary = forwarder_binary
|
||||
self.max_agent_rounds = max_agent_rounds
|
||||
self.readiness_timeout_seconds = readiness_timeout_seconds
|
||||
self.poll_seconds = poll_seconds
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
instruction: str,
|
||||
environment: BaseEnvironment,
|
||||
manifest: ExperimentManifest,
|
||||
trial: TrialHandle,
|
||||
) -> RuntimeResult:
|
||||
classes = self._classes_by_agent_id(manifest, trial.credentials)
|
||||
orchestrator = next(c for c in trial.credentials if c.role == "orchestrator")
|
||||
workers = [c for c in trial.credentials if c.agent_id != orchestrator.agent_id]
|
||||
if not workers:
|
||||
raise RuntimeLaunchError("Buzz orchestration requires at least one worker")
|
||||
trial_dir = self.logs_dir / "buzz"
|
||||
trial_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
agents: list[_Agent] = []
|
||||
infra: list[_Agent] = []
|
||||
try:
|
||||
await self._install_stack(environment)
|
||||
forwarder = await self._start_forwarder(environment, trial)
|
||||
if forwarder is not None:
|
||||
infra.append(forwarder)
|
||||
await self._buzz_json(
|
||||
trial.user, trial, "users", "set-profile", "--name",
|
||||
trial.user.agent_id,
|
||||
)
|
||||
for credential in trial.credentials:
|
||||
await self._buzz_json(
|
||||
credential, trial, "users", "set-profile", "--name",
|
||||
credential.agent_id,
|
||||
)
|
||||
agents.append(
|
||||
await self._launch_agent(
|
||||
environment=environment,
|
||||
trial=trial,
|
||||
credential=credential,
|
||||
agent_class=classes[credential.agent_id],
|
||||
trial_dir=trial_dir,
|
||||
)
|
||||
)
|
||||
await self._wait_for_agents_ready(
|
||||
environment, agents, trial.channel_id, infra
|
||||
)
|
||||
# The task arrives exactly as it would in production Buzz: a
|
||||
# user prompt @mentioning the orchestrator. The harness never
|
||||
# speaks as any agent.
|
||||
await self._send(
|
||||
trial.user, trial, f"@{orchestrator.agent_id} {instruction}"
|
||||
)
|
||||
final_message = await asyncio.wait_for(
|
||||
self._wait_for_done(environment, orchestrator, trial, agents + infra),
|
||||
timeout=manifest.trial_budget.timeout_seconds,
|
||||
)
|
||||
await self._verify_m1_output(environment, manifest)
|
||||
finally:
|
||||
await self._stop_agents(environment, agents + infra)
|
||||
await self._collect_logs(environment, trial_dir)
|
||||
|
||||
return RuntimeResult(
|
||||
metadata={
|
||||
"completion_message_id": final_message["id"],
|
||||
"completion_message": final_message["content"],
|
||||
"agent_runtime": "in-container",
|
||||
"agent_hints_enabled": False,
|
||||
"task_seed": "user-identity-prompt",
|
||||
"agent_max_rounds": {
|
||||
credential.agent_id: (
|
||||
classes[credential.agent_id].budget.max_calls
|
||||
or self.max_agent_rounds
|
||||
)
|
||||
for credential in trial.credentials
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# -- container setup ------------------------------------------------------
|
||||
|
||||
async def _install_stack(self, environment: BaseEnvironment) -> None:
|
||||
"""Upload the pinned Linux binaries into the task container."""
|
||||
uploads = {
|
||||
f"{REMOTE_BIN}/buzz-acp": self.buzz_acp_binary,
|
||||
f"{REMOTE_BIN}/buzz-agent": self.buzz_agent_binary,
|
||||
f"{REMOTE_BIN}/buzz-dev-mcp": self.buzz_dev_mcp_binary,
|
||||
}
|
||||
if self.relay_gateway:
|
||||
uploads[FORWARDER] = self.forwarder_binary
|
||||
for source in uploads.values():
|
||||
if not Path(source).is_file():
|
||||
raise RuntimeLaunchError(f"agent binary not found: {source}")
|
||||
result = await environment.exec(
|
||||
f"mkdir -p {REMOTE_BIN} {REMOTE_PROMPTS} {REMOTE_LOGS}"
|
||||
)
|
||||
if result.return_code != 0:
|
||||
raise RuntimeLaunchError(
|
||||
f"cannot create {REMOTE_ROOT} in the task container: "
|
||||
f"{result.stderr or result.stdout}"
|
||||
)
|
||||
for target, source in uploads.items():
|
||||
await environment.upload_file(source, target)
|
||||
await environment.exec(f"chmod 0755 {REMOTE_BIN}/*")
|
||||
|
||||
async def _start_forwarder(
|
||||
self, environment: BaseEnvironment, trial: TrialHandle
|
||||
) -> _Agent | None:
|
||||
"""Bridge the agents' canonical relay address to the real gateway.
|
||||
|
||||
The relay resolves its tenant from the request ``Host`` header, so the
|
||||
agents must dial the exact authority its community row is bound to —
|
||||
``trial.relay_ws_url``. When that address is loopback inside the task
|
||||
container but the relay lives on the Docker host, this starts the
|
||||
uploaded forwarder listening on the canonical address and pumping the
|
||||
byte stream to ``relay_gateway``. Returns ``None`` when no gateway is
|
||||
configured (the relay is reachable directly).
|
||||
"""
|
||||
if not self.relay_gateway:
|
||||
return None
|
||||
# Listen on the IPv4 loopback explicitly: binding the name `localhost`
|
||||
# would pick whichever address family resolves first, while clients
|
||||
# iterate both — pinning v4 makes the pair deterministic. The Host
|
||||
# header the relay tenant-binds on comes from the URL the agents
|
||||
# dial (trial.relay_ws_url), not from the socket address.
|
||||
listen = self._ws_authority(trial.relay_ws_url).replace(
|
||||
"localhost", "127.0.0.1", 1
|
||||
)
|
||||
log = FORWARDER_LOG
|
||||
command = (
|
||||
f"{shlex.quote(FORWARDER)} {shlex.quote(listen)} "
|
||||
f"{shlex.quote(self.relay_gateway)} </dev/null "
|
||||
f">{shlex.quote(log)} 2>&1 & echo $!"
|
||||
)
|
||||
result = await environment.exec(command)
|
||||
try:
|
||||
pid = int((result.stdout or "").strip().splitlines()[-1])
|
||||
except (ValueError, IndexError) as error:
|
||||
raise RuntimeLaunchError(
|
||||
f"cannot launch relay forwarder: {result.stderr or result.stdout}"
|
||||
) from error
|
||||
forwarder = _Agent(
|
||||
AgentCredential(
|
||||
agent_id="relay-forwarder", role="infra",
|
||||
nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="",
|
||||
llm_endpoint="", llm_api_key="",
|
||||
),
|
||||
pid, log, log,
|
||||
)
|
||||
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
|
||||
while True:
|
||||
probe = await environment.exec(f"cat {shlex.quote(log)} 2>/dev/null")
|
||||
if "forwarding" in (probe.stdout or ""):
|
||||
return forwarder
|
||||
await self._raise_for_dead_agents(environment, [forwarder])
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise RuntimeLaunchError(
|
||||
"relay forwarder did not report readiness; "
|
||||
f"see {log} in the trial artifacts"
|
||||
)
|
||||
await asyncio.sleep(self.poll_seconds)
|
||||
|
||||
@staticmethod
|
||||
def _ws_authority(relay_ws_url: str) -> str:
|
||||
"""``host:port`` from a ws:// URL — the forwarder's listen address."""
|
||||
if not relay_ws_url.startswith("ws://"):
|
||||
raise RuntimeLaunchError(
|
||||
"relay_gateway forwarding requires a ws:// relay_ws_url"
|
||||
)
|
||||
authority = relay_ws_url.removeprefix("ws://").split("/", 1)[0]
|
||||
if ":" not in authority:
|
||||
authority += ":80"
|
||||
return authority
|
||||
|
||||
async def _launch_agent(
|
||||
self,
|
||||
*,
|
||||
environment: BaseEnvironment,
|
||||
trial: TrialHandle,
|
||||
credential: AgentCredential,
|
||||
agent_class: AgentClass,
|
||||
trial_dir: Path,
|
||||
) -> _Agent:
|
||||
if not credential.llm_endpoint:
|
||||
raise RuntimeLaunchError("credential llm_endpoint must not be empty")
|
||||
endpoint = self.endpoints.get(credential.llm_endpoint)
|
||||
if endpoint is None:
|
||||
raise RuntimeLaunchError(
|
||||
f"no launch config for endpoint {credential.llm_endpoint!r}"
|
||||
)
|
||||
self._reject_identity_overrides(endpoint)
|
||||
prompt_path = self.artifact_root / agent_class.prompt.path
|
||||
self._verify_artifact(prompt_path, agent_class.prompt.sha256)
|
||||
composed = self._compose_system_prompt(
|
||||
trial_dir=trial_dir,
|
||||
trial=trial,
|
||||
credential=credential,
|
||||
persona_path=prompt_path,
|
||||
)
|
||||
remote_prompt = f"{REMOTE_PROMPTS}/{credential.agent_id}.system-prompt.md"
|
||||
await environment.upload_file(composed, remote_prompt)
|
||||
|
||||
stdout_log = f"{REMOTE_LOGS}/{credential.agent_id}.stdout.log"
|
||||
stderr_log = f"{REMOTE_LOGS}/{credential.agent_id}.stderr.log"
|
||||
env = self._agent_env(
|
||||
trial=trial,
|
||||
credential=credential,
|
||||
agent_class=agent_class,
|
||||
endpoint=endpoint,
|
||||
remote_prompt=remote_prompt,
|
||||
)
|
||||
command = (
|
||||
f"{shlex.quote(f'{REMOTE_BIN}/buzz-acp')} </dev/null "
|
||||
f">{shlex.quote(stdout_log)} 2>{shlex.quote(stderr_log)} & echo $!"
|
||||
)
|
||||
result = await environment.exec(command, env=env)
|
||||
try:
|
||||
pid = int((result.stdout or "").strip().splitlines()[-1])
|
||||
except (ValueError, IndexError) as error:
|
||||
raise RuntimeLaunchError(
|
||||
f"cannot launch agent {credential.agent_id}: "
|
||||
f"{result.stderr or result.stdout}"
|
||||
) from error
|
||||
return _Agent(credential, pid, stdout_log, stderr_log)
|
||||
|
||||
def _agent_env(
|
||||
self,
|
||||
*,
|
||||
trial: TrialHandle,
|
||||
credential: AgentCredential,
|
||||
agent_class: AgentClass,
|
||||
endpoint: EndpointLaunchConfig,
|
||||
remote_prompt: str,
|
||||
) -> dict[str, str]:
|
||||
"""The desktop-launch environment: real acp/agent/dev-mcp wiring."""
|
||||
return {
|
||||
**endpoint.env,
|
||||
"BUZZ_RELAY_URL": trial.relay_ws_url,
|
||||
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
|
||||
# Desktop parity: the GUI also sets NOSTR_PRIVATE_KEY on buzz-acp
|
||||
# so buzz-dev-mcp's shim can wire git auth/signing for the agent.
|
||||
"NOSTR_PRIVATE_KEY": credential.nostr_secret_key,
|
||||
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
|
||||
"BUZZ_ACP_AGENT_COMMAND": f"{REMOTE_BIN}/buzz-agent",
|
||||
"BUZZ_ACP_AGENT_ARGS": "",
|
||||
"BUZZ_ACP_MCP_COMMAND": f"{REMOTE_BIN}/buzz-dev-mcp",
|
||||
"BUZZ_ACP_CHANNELS": trial.channel_id,
|
||||
"BUZZ_ACP_SUBSCRIBE": "mentions",
|
||||
"BUZZ_ACP_RESPOND_TO": "anyone",
|
||||
"BUZZ_ACP_NO_MEMORY": "true",
|
||||
"BUZZ_ACP_SYSTEM_PROMPT_FILE": remote_prompt,
|
||||
"BUZZ_AGENT_PROVIDER": endpoint.provider,
|
||||
"BUZZ_AGENT_MODEL": credential.llm_endpoint,
|
||||
"BUZZ_AGENT_MAX_OUTPUT_TOKENS": str(
|
||||
agent_class.generation.max_output_tokens
|
||||
),
|
||||
"BUZZ_AGENT_MAX_CONTEXT_TOKENS": str(
|
||||
agent_class.generation.context_window_tokens
|
||||
),
|
||||
"BUZZ_AGENT_MAX_ROUNDS": str(
|
||||
agent_class.budget.max_calls or self.max_agent_rounds
|
||||
),
|
||||
# The pinned persona is the whole prompt: no hint-file or skill
|
||||
# discovery from the task filesystem (metadata reports this).
|
||||
"BUZZ_AGENT_NO_HINTS": "1",
|
||||
endpoint.api_key_env: credential.llm_api_key,
|
||||
}
|
||||
|
||||
# -- lifecycle -------------------------------------------------------------
|
||||
|
||||
async def _wait_for_agents_ready(
|
||||
self,
|
||||
environment: BaseEnvironment,
|
||||
agents: list[_Agent],
|
||||
channel_id: str,
|
||||
infra: list[_Agent] | None = None,
|
||||
) -> None:
|
||||
"""Wait until every ACP process confirms its trial-channel subscription."""
|
||||
marker = f"subscribed to channel {channel_id}"
|
||||
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
|
||||
pending = {agent.credential.agent_id: agent for agent in agents}
|
||||
while pending:
|
||||
await self._raise_for_dead_agents(environment, agents + (infra or []))
|
||||
for agent_id, agent in list(pending.items()):
|
||||
result = await environment.exec(
|
||||
f"cat {shlex.quote(agent.stdout_log)} "
|
||||
f"{shlex.quote(agent.stderr_log)} 2>/dev/null"
|
||||
)
|
||||
if marker in (result.stdout or ""):
|
||||
del pending[agent_id]
|
||||
if not pending:
|
||||
return
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise RuntimeLaunchError(
|
||||
"agents did not subscribe to trial channel before readiness "
|
||||
f"timeout: {sorted(pending)}"
|
||||
)
|
||||
await asyncio.sleep(self.poll_seconds)
|
||||
|
||||
async def _wait_for_done(
|
||||
self,
|
||||
environment: BaseEnvironment,
|
||||
orchestrator: AgentCredential,
|
||||
trial: TrialHandle,
|
||||
agents: list[_Agent],
|
||||
) -> dict[str, Any]:
|
||||
"""Observe the channel as the trial user until the orchestrator posts DONE.
|
||||
|
||||
Observation only: the harness never speaks as any agent. If the team
|
||||
stalls, the trial times out and the stall is the measured result.
|
||||
"""
|
||||
polls = 0
|
||||
while True:
|
||||
if polls % LIVENESS_EVERY == 0:
|
||||
await self._raise_for_dead_agents(environment, agents)
|
||||
polls += 1
|
||||
messages = await self._buzz_json(
|
||||
trial.user, trial,
|
||||
"messages", "get", "--channel", trial.channel_id,
|
||||
"--limit", "100",
|
||||
)
|
||||
for message in messages:
|
||||
if message.get("pubkey") == orchestrator.nostr_pubkey and str(
|
||||
message.get("content", "")
|
||||
).startswith("DONE:"):
|
||||
return message
|
||||
await asyncio.sleep(self.poll_seconds)
|
||||
|
||||
async def _raise_for_dead_agents(
|
||||
self, environment: BaseEnvironment, agents: list[_Agent]
|
||||
) -> None:
|
||||
if not agents:
|
||||
return
|
||||
probes = "; ".join(
|
||||
f"kill -0 {agent.pid} 2>/dev/null || echo DEAD:{agent.credential.agent_id}"
|
||||
for agent in agents
|
||||
)
|
||||
result = await environment.exec(probes)
|
||||
dead = [
|
||||
line.removeprefix("DEAD:")
|
||||
for line in (result.stdout or "").splitlines()
|
||||
if line.startswith("DEAD:")
|
||||
]
|
||||
if dead:
|
||||
raise RuntimeLaunchError(
|
||||
f"agent processes exited early: {sorted(dead)}; "
|
||||
f"see {REMOTE_LOGS} in the trial artifacts"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _stop_agents(
|
||||
environment: BaseEnvironment, agents: list[_Agent]
|
||||
) -> None:
|
||||
"""Terminate every process of the uploaded stack (acp, agent, mcp)."""
|
||||
if not agents:
|
||||
return
|
||||
# Match by cmdline prefix via /proc: pkill/procps is not guaranteed
|
||||
# to exist in task images, the /proc filesystem is.
|
||||
sweep = (
|
||||
"for d in /proc/[0-9]*; do "
|
||||
f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null "
|
||||
"&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true"
|
||||
)
|
||||
try:
|
||||
await environment.exec(sweep)
|
||||
await asyncio.sleep(2)
|
||||
await environment.exec(sweep.replace("-TERM", "-KILL"))
|
||||
except Exception: # noqa: BLE001 — environment may already be gone
|
||||
pass
|
||||
|
||||
async def _collect_logs(
|
||||
self, environment: BaseEnvironment, trial_dir: Path
|
||||
) -> None:
|
||||
try:
|
||||
await environment.download_dir(REMOTE_LOGS, trial_dir)
|
||||
except Exception: # noqa: BLE001 — best effort; env may be torn down
|
||||
pass
|
||||
|
||||
# -- Buzz CLI as the trial user / provisioning identities -------------------
|
||||
|
||||
@staticmethod
|
||||
async def _verify_m1_output(
|
||||
environment: BaseEnvironment, manifest: ExperimentManifest
|
||||
) -> None:
|
||||
"""Fail M1 immediately unless the artifact satisfies the grader contract."""
|
||||
if manifest.condition != "M1-hello-world":
|
||||
return
|
||||
result = await environment.exec(
|
||||
'python3 -c "from pathlib import Path; '
|
||||
"p = Path('/app/hello.txt'); "
|
||||
"assert p.is_file() and p.read_text().strip() == 'Hello, world!'\""
|
||||
)
|
||||
if result.return_code != 0:
|
||||
detail = (
|
||||
result.stderr or result.stdout or "grader-equivalent check failed"
|
||||
).strip()
|
||||
raise RuntimeLaunchError(
|
||||
"M1 pre-verifier sanity probe failed: /app/hello.txt must exist "
|
||||
f"and its stripped text must equal 'Hello, world!' ({detail})"
|
||||
)
|
||||
|
||||
async def _send(
|
||||
self, credential: AgentCredential, trial: TrialHandle, content: str
|
||||
) -> None:
|
||||
await self._buzz_json(
|
||||
credential, trial,
|
||||
"messages", "send", "--channel", trial.channel_id,
|
||||
"--content", content,
|
||||
)
|
||||
|
||||
async def _buzz_json(
|
||||
self, credential: AgentCredential, trial: TrialHandle, *args: str
|
||||
) -> Any:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.buzz_cli_binary,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={
|
||||
**os.environ,
|
||||
"BUZZ_RELAY_URL": self._user_relay_url(trial),
|
||||
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
|
||||
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
|
||||
},
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeLaunchError(
|
||||
f"buzz {shlex.join(args)} exited {process.returncode}: "
|
||||
f"{stderr.decode(errors='replace').strip()}"
|
||||
)
|
||||
try:
|
||||
return json.loads(stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise RuntimeLaunchError("buzz returned invalid JSON") from error
|
||||
|
||||
@staticmethod
|
||||
def _user_relay_url(trial: TrialHandle) -> str:
|
||||
"""The relay as reachable from the HOST (user identity, harness).
|
||||
|
||||
``trial.relay_ws_url`` is the container view (the agents' runtime);
|
||||
``trial.user_relay_url`` is the host view. Fall back to deriving an
|
||||
http URL from the ws URL for handles minted before v1.2.
|
||||
"""
|
||||
if trial.user_relay_url:
|
||||
return trial.user_relay_url
|
||||
return BuzzContainerRuntime._cli_relay_url(trial.relay_ws_url)
|
||||
|
||||
@staticmethod
|
||||
def _cli_relay_url(relay_ws_url: str) -> str:
|
||||
if relay_ws_url.startswith("ws://"):
|
||||
return f"http://{relay_ws_url.removeprefix('ws://')}"
|
||||
if relay_ws_url.startswith("wss://"):
|
||||
return f"https://{relay_ws_url.removeprefix('wss://')}"
|
||||
raise RuntimeLaunchError("trial relay_ws_url must use ws:// or wss://")
|
||||
|
||||
# -- manifest plumbing -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _classes_by_agent_id(
|
||||
manifest: ExperimentManifest, credentials: tuple[AgentCredential, ...]
|
||||
) -> dict[str, AgentClass]:
|
||||
by_id = {entry.id: entry for entry in manifest.roster}
|
||||
result: dict[str, AgentClass] = {}
|
||||
for credential in credentials:
|
||||
class_id, separator, index = credential.agent_id.rpartition("-")
|
||||
match = by_id.get(class_id)
|
||||
if not separator or not index.isdigit() or match is None:
|
||||
raise RuntimeLaunchError(
|
||||
f"credential {credential.agent_id!r} does not match a roster class"
|
||||
)
|
||||
if credential.role != match.kind:
|
||||
raise RuntimeLaunchError(
|
||||
f"credential {credential.agent_id!r} role does not match manifest"
|
||||
)
|
||||
result[credential.agent_id] = match
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _verify_artifact(path: Path, expected_sha256: str) -> None:
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except OSError as error:
|
||||
raise RuntimeLaunchError(f"cannot read prompt {path}: {error}") from error
|
||||
if actual != expected_sha256:
|
||||
raise RuntimeLaunchError(
|
||||
f"prompt hash mismatch for {path}: expected {expected_sha256}, got {actual}"
|
||||
)
|
||||
|
||||
def _compose_system_prompt(
|
||||
self,
|
||||
*,
|
||||
trial_dir: Path,
|
||||
trial: TrialHandle,
|
||||
credential: AgentCredential,
|
||||
persona_path: Path,
|
||||
) -> Path:
|
||||
"""Append the trial's team roster to the pinned persona.
|
||||
|
||||
The analogue of a production Buzz workspace's team context: each agent
|
||||
knows its own identity, its channel, the user it reports to, and its
|
||||
teammates' names, pubkeys, and roles from its system prompt — it never
|
||||
has to discover them over the relay.
|
||||
"""
|
||||
persona = persona_path.read_text(encoding="utf-8")
|
||||
lines = [
|
||||
"",
|
||||
"## Your team",
|
||||
"",
|
||||
f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).",
|
||||
f"The team coordinates in Buzz channel `{trial.channel_id}`.",
|
||||
f"Tasks come from the user `{trial.user.agent_id}` "
|
||||
f"(pubkey `{trial.user.nostr_pubkey}`); address your final report "
|
||||
"to them.",
|
||||
"",
|
||||
"| Name | Role | Pubkey |",
|
||||
"|------|------|--------|",
|
||||
]
|
||||
for teammate in trial.credentials:
|
||||
if teammate.agent_id == credential.agent_id:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {teammate.agent_id} | {teammate.role} "
|
||||
f"| `{teammate.nostr_pubkey}` |"
|
||||
)
|
||||
composed = persona + "\n".join(lines) + "\n"
|
||||
path = trial_dir / f"{credential.agent_id}.system-prompt.md"
|
||||
path.write_text(composed, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _reject_identity_overrides(endpoint: EndpointLaunchConfig) -> None:
|
||||
forbidden = {
|
||||
"BUZZ_RELAY_URL",
|
||||
"BUZZ_PRIVATE_KEY",
|
||||
"BUZZ_AUTH_TAG",
|
||||
"BUZZ_ACP_CHANNELS",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
}
|
||||
overlap = forbidden & endpoint.env.keys()
|
||||
if overlap:
|
||||
raise RuntimeLaunchError(
|
||||
f"endpoint env cannot override trial identity: {sorted(overlap)}"
|
||||
)
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Validated, content-addressed experiment manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Self
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""Raised when a manifest cannot be loaded or validated."""
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
"""Base model that rejects misspelled or unrecognised manifest fields."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class ArtifactRef(StrictModel):
|
||||
"""Reference to immutable prompt, persona, or skill content."""
|
||||
|
||||
path: str = Field(min_length=1)
|
||||
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class GenerationConfig(StrictModel):
|
||||
"""Model generation controls frozen for a condition."""
|
||||
|
||||
temperature: float = Field(default=0.0, ge=0.0)
|
||||
max_output_tokens: int = Field(gt=0)
|
||||
context_window_tokens: int = Field(gt=0)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentBudget(StrictModel):
|
||||
"""Optional per-agent live safety limits."""
|
||||
|
||||
max_calls: int | None = Field(default=None, gt=0)
|
||||
max_input_tokens: int | None = Field(default=None, gt=0)
|
||||
max_output_tokens: int | None = Field(default=None, gt=0)
|
||||
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class Price(StrictModel):
|
||||
"""Frozen USD rates for one endpoint revision, per million tokens."""
|
||||
|
||||
input_per_million_usd: float = Field(ge=0)
|
||||
cached_input_per_million_usd: float = Field(ge=0)
|
||||
output_per_million_usd: float = Field(ge=0)
|
||||
|
||||
|
||||
class AgentClass(StrictModel):
|
||||
"""A homogeneous class of agents in the trial roster."""
|
||||
|
||||
id: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9._-]*$")
|
||||
kind: Literal["orchestrator", "worker"]
|
||||
role: str = Field(min_length=1)
|
||||
count: int = Field(gt=0)
|
||||
endpoint: str = Field(min_length=1)
|
||||
model_revision: str = Field(min_length=1)
|
||||
prompt: ArtifactRef
|
||||
persona: ArtifactRef | None = None
|
||||
skills: tuple[ArtifactRef, ...] = ()
|
||||
generation: GenerationConfig
|
||||
budget: AgentBudget = AgentBudget()
|
||||
concurrency: int = Field(default=1, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_concurrency(self) -> Self:
|
||||
if self.concurrency > self.count:
|
||||
raise ValueError("concurrency cannot exceed count")
|
||||
return self
|
||||
|
||||
|
||||
class TrialBudget(StrictModel):
|
||||
"""Trial-wide hard limits enforced by the live runtime, not async receipts."""
|
||||
|
||||
timeout_seconds: int = Field(gt=0)
|
||||
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class ExperimentManifest(StrictModel):
|
||||
"""Complete immutable input defining one benchmark condition."""
|
||||
|
||||
schema_version: Literal["1"] = "1"
|
||||
condition: str = Field(min_length=1)
|
||||
roster: tuple[AgentClass, ...] = Field(min_length=1)
|
||||
prices: dict[str, Price]
|
||||
trial_budget: TrialBudget
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_roster(self) -> Self:
|
||||
ids = [entry.id for entry in self.roster]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("roster class ids must be unique")
|
||||
orchestrators = sum(
|
||||
entry.count for entry in self.roster if entry.kind == "orchestrator"
|
||||
)
|
||||
if orchestrators != 1:
|
||||
raise ValueError("roster must contain exactly one orchestrator")
|
||||
endpoints = {entry.endpoint for entry in self.roster}
|
||||
missing_prices = sorted(endpoints - self.prices.keys())
|
||||
if missing_prices:
|
||||
raise ValueError(f"prices missing for endpoints: {missing_prices}")
|
||||
return self
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
"""Return stable UTF-8 JSON independent of YAML formatting and key order."""
|
||||
data = self.model_dump(mode="json", exclude_none=False)
|
||||
return json.dumps(
|
||||
data,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
"""Return the condition identity derived from canonical manifest content."""
|
||||
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def load(cls, source: str | Path | dict[str, Any]) -> Self:
|
||||
"""Load a manifest from a YAML/JSON file or an already-decoded mapping."""
|
||||
if isinstance(source, dict):
|
||||
raw = source
|
||||
else:
|
||||
path = Path(source).expanduser()
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as error:
|
||||
raise ManifestError(f"cannot load manifest {path}: {error}") from error
|
||||
if not isinstance(raw, dict):
|
||||
raise ManifestError("manifest root must be a mapping")
|
||||
try:
|
||||
return cls.model_validate(raw)
|
||||
except ValueError as error:
|
||||
raise ManifestError(f"invalid manifest: {error}") from error
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Typed boundary between the Harbor adapter and Buzz trial provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from .manifest import ExperimentManifest
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentCredential:
|
||||
"""One trial-scoped Buzz identity and attributed LLM credential."""
|
||||
|
||||
agent_id: str
|
||||
role: str
|
||||
nostr_secret_key: str
|
||||
nostr_pubkey: str
|
||||
nostr_auth_tag: str
|
||||
llm_endpoint: str
|
||||
llm_api_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrialHandle:
|
||||
"""Provisioned Buzz resources owned by one Harbor trial."""
|
||||
|
||||
run_id: str
|
||||
trial_id: str
|
||||
manifest_hash: str
|
||||
relay_ws_url: str
|
||||
channel_id: str
|
||||
credentials: tuple[AgentCredential, ...]
|
||||
# The trial's human-analogue identity: owns the channel, posts the task
|
||||
# as a user prompt, and receives the final report. Never runs an agent
|
||||
# process, so its llm_endpoint and llm_api_key are empty strings.
|
||||
user: AgentCredential
|
||||
# v1.2 (additive): the relay as reachable from the HOST, where the user
|
||||
# identity and the harness run. ``relay_ws_url`` is the view from the
|
||||
# agents' runtime (the task container). Empty means both views coincide.
|
||||
user_relay_url: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TrialProvisioner(Protocol):
|
||||
"""Creates and tears down trial-isolated Buzz resources synchronously."""
|
||||
|
||||
def create_trial(
|
||||
self,
|
||||
run_id: str,
|
||||
trial_id: str,
|
||||
manifest: ExperimentManifest,
|
||||
channel_label: str | None = None,
|
||||
) -> TrialHandle: ...
|
||||
|
||||
def teardown(self, handle: TrialHandle) -> None: ...
|
||||
|
||||
def healthcheck(self) -> None: ...
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Runtime contract kept separate from Buzz resource provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from harbor.environments.base import BaseEnvironment
|
||||
|
||||
from .manifest import ExperimentManifest
|
||||
from .provisioning import TrialHandle
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeResult:
|
||||
"""Aggregate values returned by an orchestration runtime."""
|
||||
|
||||
input_tokens: int = 0
|
||||
cached_input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost_usd: float = 0.0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class OrchestraRuntime(Protocol):
|
||||
"""Runs the provisioned roster and writes trial artifacts into logs_dir."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
instruction: str,
|
||||
environment: BaseEnvironment,
|
||||
manifest: ExperimentManifest,
|
||||
trial: TrialHandle,
|
||||
) -> RuntimeResult: ...
|
||||
@@ -1,18 +0,0 @@
|
||||
# Benchmark-only overrides for deploy/compose/compose.yml.
|
||||
#
|
||||
# The production bundle stays untouched; this file publishes what the
|
||||
# benchmark harness needs on the host:
|
||||
# - relay Prometheus metrics (:9102) for scraping during runs
|
||||
# - Postgres (:5433) for the provisioner and receipts ingestion
|
||||
# (5433 to avoid colliding with other local Postgres instances)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --env-file .env \
|
||||
# -f compose.yml -f <this file> up -d --wait
|
||||
services:
|
||||
relay:
|
||||
ports:
|
||||
- "${BUZZ_METRICS_HOST_PORT:-9102}:9102"
|
||||
postgres:
|
||||
ports:
|
||||
- "${BUZZ_PG_HOST_PORT:-5433}:5432"
|
||||
@@ -1,26 +0,0 @@
|
||||
# Endpoint launch configs
|
||||
|
||||
Deployment-time mapping from manifest endpoint names to
|
||||
`EndpointLaunchConfig` (`provider` / `api_key_env` / `env`), passed to
|
||||
`harbor run` as `--agent-kwarg endpoint_config=<path>`. This is deployment
|
||||
config, deliberately OUTSIDE the immutable condition manifest — the manifest
|
||||
endpoint string remains the join key.
|
||||
|
||||
Every key in these files must be a manifest endpoint name; the loader treats
|
||||
all entries as endpoint configs (no comment keys).
|
||||
|
||||
## m1-local.json
|
||||
|
||||
M1 wiring proof: both placeholder endpoints resolve to one local llama-server
|
||||
(OpenAI-compatible, `http://127.0.0.1:8091/v1`, no cloud keys).
|
||||
|
||||
buzz-agent env contract (crates/buzz-agent/src/config.rs, pinned at the M1
|
||||
binary SHA): `provider=openai` reads `OPENAI_COMPAT_API_KEY` +
|
||||
`OPENAI_COMPAT_BASE_URL`; the runtime sets `BUZZ_AGENT_MODEL` from the
|
||||
manifest endpoint name, which overrides `OPENAI_COMPAT_MODEL` — llama-server
|
||||
ignores the model name, so the placeholder value is harmless there.
|
||||
llama-server needs no real key; the provisioner's per-endpoint
|
||||
`llm_api_keys` map supplies a dummy value.
|
||||
|
||||
The Databricks pilot config is the same file shape with real serving
|
||||
endpoint hosts/keys.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"claude-sonnet-4-6": {
|
||||
"provider": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"env": {}
|
||||
},
|
||||
"claude-haiku-4-5": {
|
||||
"provider": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"local/placeholder-orchestrator": {
|
||||
"provider": "openai",
|
||||
"api_key_env": "OPENAI_COMPAT_API_KEY",
|
||||
"env": {
|
||||
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
|
||||
}
|
||||
},
|
||||
"local/placeholder-worker": {
|
||||
"provider": "openai",
|
||||
"api_key_env": "OPENAI_COMPAT_API_KEY",
|
||||
"env": {
|
||||
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[project]
|
||||
name = "harbor-buzz-testbed"
|
||||
version = "0.1.0"
|
||||
description = "Trial provisioning and local Buzz stack tooling for harbor-buzz-orchestra"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"harbor-buzz-orchestra",
|
||||
"coincurve>=20",
|
||||
"psycopg[binary]>=3.2",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.4",
|
||||
"ruff>=0.15",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
harbor-buzz-orchestra = { path = "..", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 88
|
||||
@@ -1,72 +0,0 @@
|
||||
-- Benchmark schema for harbor-buzz-orchestra runs.
|
||||
--
|
||||
-- Lives in the shared Postgres instance but is OWNED BY THE HARNESS, never by
|
||||
-- Buzz migrations (canonical plan §two-domain rule). Idempotent: safe to apply
|
||||
-- on every testbed bring-up.
|
||||
--
|
||||
-- docker exec -i <postgres> psql -U buzz -d buzz < sql/benchmark_schema.sql
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS benchmark;
|
||||
|
||||
-- One row per provisioned trial; written by BuzzTrialProvisioner.
|
||||
CREATE TABLE IF NOT EXISTS benchmark.trial_manifest (
|
||||
run_id text NOT NULL,
|
||||
trial_id uuid NOT NULL,
|
||||
manifest_hash text NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
handle jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
archived_at timestamptz,
|
||||
PRIMARY KEY (run_id, trial_id)
|
||||
);
|
||||
|
||||
-- Immutable LLM receipts, ingested post-run from the accounting path
|
||||
-- (Databricks AI Gateway inference tables first; LiteLLM shim fallback).
|
||||
-- Authoritative for tokens/cost and orchestrator-vs-worker attribution;
|
||||
-- Harbor AgentContext totals are a reconciliation checksum only.
|
||||
CREATE TABLE IF NOT EXISTS benchmark.llm_receipts (
|
||||
receipt_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id text NOT NULL,
|
||||
trial_id uuid NOT NULL,
|
||||
agent_id text NOT NULL,
|
||||
role text NOT NULL,
|
||||
condition text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
model_revision text NOT NULL,
|
||||
request_id text NOT NULL, -- gateway request identity
|
||||
requested_at timestamptz NOT NULL,
|
||||
latency_ms integer,
|
||||
input_tokens bigint NOT NULL DEFAULT 0,
|
||||
cached_input_tokens bigint NOT NULL DEFAULT 0,
|
||||
output_tokens bigint NOT NULL DEFAULT 0,
|
||||
cost_usd numeric(12, 6),
|
||||
source text NOT NULL, -- 'ai_gateway' | 'litellm' | 'client'
|
||||
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (source, request_id),
|
||||
FOREIGN KEY (run_id, trial_id)
|
||||
REFERENCES benchmark.trial_manifest (run_id, trial_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS llm_receipts_trial_idx
|
||||
ON benchmark.llm_receipts (run_id, trial_id, agent_id);
|
||||
|
||||
-- Harness-recorded timing spans (monotonic clocks are authoritative for
|
||||
-- latency). kind examples: 'trial', 'llm_call', 'terminal_exec',
|
||||
-- 'terminal_queue_wait' — queue-wait is recorded separately from execution so
|
||||
-- speed can be reported both as-run and queue-adjusted under the M1
|
||||
-- serialized-broker policy.
|
||||
CREATE TABLE IF NOT EXISTS benchmark.spans (
|
||||
span_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id text NOT NULL,
|
||||
trial_id uuid NOT NULL,
|
||||
agent_id text, -- NULL for trial-level spans
|
||||
kind text NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
duration_ms bigint NOT NULL CHECK (duration_ms >= 0),
|
||||
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
FOREIGN KEY (run_id, trial_id)
|
||||
REFERENCES benchmark.trial_manifest (run_id, trial_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS spans_trial_kind_idx
|
||||
ON benchmark.spans (run_id, trial_id, kind);
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Testbed-side provisioning for harbor-buzz-orchestra trials."""
|
||||
|
||||
from .provisioner import (
|
||||
BuzzTrialProvisioner,
|
||||
ProvisioningError,
|
||||
TestbedConfig,
|
||||
provisioner_from_dict,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BuzzTrialProvisioner",
|
||||
"ProvisioningError",
|
||||
"TestbedConfig",
|
||||
"provisioner_from_dict",
|
||||
]
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Thin subprocess wrapper over the ``buzz`` CLI — the production client path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BuzzCliError(RuntimeError):
|
||||
"""A buzz CLI invocation failed."""
|
||||
|
||||
|
||||
class BuzzCli:
|
||||
"""Run buzz CLI commands as one relay identity (key + NIP-OA auth tag)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
relay_url: str,
|
||||
secret_key: str,
|
||||
auth_tag: str,
|
||||
*,
|
||||
binary: str = "buzz",
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> None:
|
||||
self._relay_url = relay_url
|
||||
self._secret_key = secret_key
|
||||
self._auth_tag = auth_tag
|
||||
self._binary = binary
|
||||
self._timeout = timeout_seconds
|
||||
|
||||
def run(self, *args: str) -> Any:
|
||||
"""Run a buzz subcommand and return its parsed JSON stdout."""
|
||||
command = [self._binary, *args]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._timeout,
|
||||
env={
|
||||
"BUZZ_RELAY_URL": self._relay_url,
|
||||
"BUZZ_PRIVATE_KEY": self._secret_key,
|
||||
"BUZZ_AUTH_TAG": self._auth_tag,
|
||||
"PATH": _path(),
|
||||
},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BuzzCliError(f"buzz {args[0]}: {error}") from error
|
||||
if completed.returncode != 0:
|
||||
raise BuzzCliError(
|
||||
f"buzz {' '.join(args)} exited {completed.returncode}: "
|
||||
f"{completed.stderr.strip() or completed.stdout.strip()}"
|
||||
)
|
||||
if not completed.stdout.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(completed.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise BuzzCliError(
|
||||
f"buzz {args[0]} returned non-JSON output: {completed.stdout[:200]!r}"
|
||||
) from error
|
||||
|
||||
def create_private_channel(self, name: str, description: str) -> str:
|
||||
"""Create a private stream channel; return its UUID."""
|
||||
response = self.run(
|
||||
"channels",
|
||||
"create",
|
||||
"--name",
|
||||
name,
|
||||
"--type",
|
||||
"stream",
|
||||
"--visibility",
|
||||
"private",
|
||||
"--description",
|
||||
description,
|
||||
)
|
||||
channel_id = response.get("channel_id") if isinstance(response, dict) else None
|
||||
if not channel_id:
|
||||
raise BuzzCliError(f"channel create returned no channel_id: {response}")
|
||||
return channel_id
|
||||
|
||||
def add_member(self, channel_id: str, pubkey: str) -> None:
|
||||
self.run(
|
||||
"channels",
|
||||
"add-member",
|
||||
"--channel",
|
||||
channel_id,
|
||||
"--pubkey",
|
||||
pubkey,
|
||||
"--role",
|
||||
"member",
|
||||
)
|
||||
|
||||
def archive_channel(self, channel_id: str) -> None:
|
||||
self.run("channels", "archive", "--channel", channel_id)
|
||||
|
||||
|
||||
def _path() -> str:
|
||||
import os
|
||||
|
||||
return os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Nostr keygen and NIP-OA owner attestation for trial agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import coincurve
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NostrKeypair:
|
||||
secret_key: str # hex
|
||||
pubkey: str # x-only hex
|
||||
|
||||
|
||||
def generate_keypair() -> NostrKeypair:
|
||||
"""Generate a fresh secp256k1 keypair in Nostr hex form."""
|
||||
key = coincurve.PrivateKey()
|
||||
return NostrKeypair(
|
||||
secret_key=key.to_hex(),
|
||||
pubkey=key.public_key_xonly.format().hex(),
|
||||
)
|
||||
|
||||
|
||||
def keypair_from_secret(secret_key: str) -> NostrKeypair:
|
||||
"""Rebuild the keypair for an existing hex secret key."""
|
||||
key = coincurve.PrivateKey(bytes.fromhex(secret_key))
|
||||
return NostrKeypair(
|
||||
secret_key=secret_key,
|
||||
pubkey=key.public_key_xonly.format().hex(),
|
||||
)
|
||||
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||
checksum = 1
|
||||
for value in values:
|
||||
top = checksum >> 25
|
||||
checksum = (checksum & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
checksum ^= generator[i] if (top >> i) & 1 else 0
|
||||
return checksum
|
||||
|
||||
|
||||
def encode_nsec(secret_key: str) -> str:
|
||||
"""Encode a hex secret key as a NIP-19 bech32 ``nsec1…`` string —
|
||||
the form the desktop GUI's key-import onboarding accepts."""
|
||||
hrp = "nsec"
|
||||
data: list[int] = []
|
||||
accumulator = bits = 0
|
||||
for byte in bytes.fromhex(secret_key):
|
||||
accumulator = accumulator << 8 | byte
|
||||
bits += 8
|
||||
while bits >= 5:
|
||||
bits -= 5
|
||||
data.append(accumulator >> bits & 31)
|
||||
if bits:
|
||||
data.append(accumulator << (5 - bits) & 31)
|
||||
expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
polymod = _bech32_polymod(expanded + data + [0] * 6) ^ 1
|
||||
checksum = [polymod >> 5 * (5 - i) & 31 for i in range(6)]
|
||||
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in data + checksum)
|
||||
|
||||
|
||||
def compute_auth_tag(
|
||||
owner_secret_key: str, agent_pubkey: str, conditions: str = ""
|
||||
) -> str:
|
||||
"""Compute the NIP-OA ``["auth", ...]`` tag authorising an agent key.
|
||||
|
||||
Mirrors crates/buzz-sdk/src/nip_oa.rs:
|
||||
sig = schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions),
|
||||
owner_secret_key). Returns the tag as a JSON string.
|
||||
"""
|
||||
owner = coincurve.PrivateKey(bytes.fromhex(owner_secret_key))
|
||||
preimage = f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode()
|
||||
signature = owner.sign_schnorr(hashlib.sha256(preimage).digest())
|
||||
return json.dumps(
|
||||
[
|
||||
"auth",
|
||||
owner.public_key_xonly.format().hex(),
|
||||
conditions,
|
||||
signature.hex(),
|
||||
],
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Channel-per-trial Buzz provisioning against a local benchmark stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psycopg
|
||||
from harbor_buzz_orchestra.manifest import ExperimentManifest
|
||||
from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
|
||||
|
||||
from .buzz_cli import BuzzCli
|
||||
from .keys import compute_auth_tag, generate_keypair, keypair_from_secret
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
"""Trial provisioning failed or was invoked inconsistently."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TestbedConfig:
|
||||
"""Connection settings for one local benchmark stack."""
|
||||
|
||||
__test__ = False # not a pytest class, despite the name
|
||||
relay_http_url: str # e.g. http://localhost:3000 — CLI + healthcheck
|
||||
relay_ws_url: str # as reachable FROM the agents' runtime
|
||||
owner_secret_key: str # relay owner key; signs NIP-OA attestations
|
||||
postgres_dsn: str # benchmark schema lives here
|
||||
llm_api_keys: dict[str, str] = dataclasses.field(default_factory=dict)
|
||||
# endpoint -> API key. v1: per-endpoint resolution; true per-agent
|
||||
# Databricks attribution is pending the AI Gateway field verification.
|
||||
# Pinned user identity (hex secret key). When set, every trial's user —
|
||||
# the human analogue that owns the channel and posts the task — is this
|
||||
# one identity instead of a fresh key per trial, so a GUI logged in as
|
||||
# that user sees every trial channel accumulate. None preserves the
|
||||
# fresh-user-per-trial behaviour.
|
||||
user_secret_key: str | None = None
|
||||
# When False, teardown leaves the trial channel unarchived (and its
|
||||
# archived_at stamp NULL) so finished trials stay visible in a GUI.
|
||||
archive_on_teardown: bool = True
|
||||
|
||||
|
||||
def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner":
|
||||
"""Harbor CLI factory for a JSON-decoded testbed configuration."""
|
||||
return BuzzTrialProvisioner(TestbedConfig(**config))
|
||||
|
||||
|
||||
class BuzzTrialProvisioner:
|
||||
"""Implements the TrialHandle v1.1 contract against a live Buzz relay.
|
||||
|
||||
Guarantees (contract PLANS/HARBOR_BUZZ_TRIALHANDLE_CONTRACT.md):
|
||||
- create_trial is synchronous and idempotent per (run_id, trial_id);
|
||||
concurrency-safe via a Postgres advisory lock on the trial key.
|
||||
- One private channel per trial; membership is exactly the trial's
|
||||
credentials, so cross-trial reads are blocked by construction.
|
||||
- Fresh keys per trial; never reused.
|
||||
- teardown archives the channel and stamps archived_at; events are
|
||||
never deleted.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TestbedConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
# -- contract surface ---------------------------------------------------
|
||||
|
||||
def create_trial(
|
||||
self,
|
||||
run_id: str,
|
||||
trial_id: str,
|
||||
manifest: ExperimentManifest,
|
||||
channel_label: str | None = None,
|
||||
) -> TrialHandle:
|
||||
manifest_hash = manifest.sha256
|
||||
with psycopg.connect(self._config.postgres_dsn) as conn:
|
||||
self._lock_trial(conn, run_id, trial_id)
|
||||
existing = self._load_trial(conn, run_id, trial_id)
|
||||
if existing is not None:
|
||||
if existing.manifest_hash != manifest_hash:
|
||||
raise ProvisioningError(
|
||||
f"trial ({run_id}, {trial_id}) already provisioned with "
|
||||
f"manifest {existing.manifest_hash}, got {manifest_hash}"
|
||||
)
|
||||
return existing
|
||||
|
||||
handle = self._provision(
|
||||
run_id, trial_id, manifest, manifest_hash, channel_label
|
||||
)
|
||||
self._store_trial(conn, handle)
|
||||
conn.commit()
|
||||
return handle
|
||||
|
||||
def teardown(self, handle: TrialHandle) -> None:
|
||||
if not self._config.archive_on_teardown:
|
||||
# GUI/spectator mode: finished trial channels stay visible.
|
||||
return
|
||||
cli = self._cli_for(handle.credentials[0])
|
||||
try:
|
||||
cli.archive_channel(handle.channel_id)
|
||||
except Exception as error: # noqa: BLE001 — idempotent re-teardown
|
||||
if "archived" not in str(error).lower():
|
||||
raise
|
||||
with psycopg.connect(self._config.postgres_dsn) as conn:
|
||||
conn.execute(
|
||||
"UPDATE benchmark.trial_manifest"
|
||||
" SET archived_at = COALESCE(archived_at, now())"
|
||||
" WHERE run_id = %s AND trial_id = %s",
|
||||
(handle.run_id, handle.trial_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def healthcheck(self) -> None:
|
||||
url = f"{self._config.relay_http_url.rstrip('/')}/_readiness"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
raise ProvisioningError(
|
||||
f"relay readiness returned {response.status}"
|
||||
)
|
||||
except (urllib.error.URLError, OSError) as error:
|
||||
raise ProvisioningError(f"relay unreachable at {url}: {error}") from error
|
||||
try:
|
||||
with psycopg.connect(self._config.postgres_dsn, connect_timeout=5) as conn:
|
||||
conn.execute("SELECT 1")
|
||||
except psycopg.Error as error:
|
||||
raise ProvisioningError(
|
||||
f"benchmark postgres unreachable: {error}"
|
||||
) from error
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
|
||||
def _provision(
|
||||
self,
|
||||
run_id: str,
|
||||
trial_id: str,
|
||||
manifest: ExperimentManifest,
|
||||
manifest_hash: str,
|
||||
channel_label: str | None,
|
||||
) -> TrialHandle:
|
||||
credentials = self._mint_credentials(manifest)
|
||||
user = self._mint_user()
|
||||
# The user identity creates the channel and invites the agents —
|
||||
# mirroring production Buzz, where a human owns the channel their
|
||||
# agents work in.
|
||||
cli = self._cli_for(user)
|
||||
name = (
|
||||
f"{channel_label}-{trial_id[:8]}"
|
||||
if channel_label
|
||||
else f"trial-{trial_id[:8]}-{manifest_hash[:8]}"
|
||||
)
|
||||
channel_id = cli.create_private_channel(
|
||||
name=name,
|
||||
description=f"run={run_id} trial={trial_id} manifest={manifest_hash}",
|
||||
)
|
||||
for credential in credentials:
|
||||
cli.add_member(channel_id, credential.nostr_pubkey)
|
||||
return TrialHandle(
|
||||
run_id=run_id,
|
||||
trial_id=trial_id,
|
||||
manifest_hash=manifest_hash,
|
||||
relay_ws_url=self._config.relay_ws_url,
|
||||
channel_id=channel_id,
|
||||
credentials=credentials,
|
||||
user=user,
|
||||
user_relay_url=self._config.relay_http_url,
|
||||
)
|
||||
|
||||
def _mint_credentials(
|
||||
self, manifest: ExperimentManifest
|
||||
) -> tuple[AgentCredential, ...]:
|
||||
roster = sorted(manifest.roster, key=lambda e: e.kind != "orchestrator")
|
||||
credentials: list[AgentCredential] = []
|
||||
for entry in roster:
|
||||
api_key = self._config.llm_api_keys.get(entry.endpoint)
|
||||
if api_key is None:
|
||||
raise ProvisioningError(
|
||||
f"no LLM API key configured for endpoint {entry.endpoint!r}"
|
||||
)
|
||||
for index in range(1, entry.count + 1):
|
||||
keypair = generate_keypair()
|
||||
credentials.append(
|
||||
AgentCredential(
|
||||
agent_id=f"{entry.id}-{index}",
|
||||
role=entry.kind,
|
||||
nostr_secret_key=keypair.secret_key,
|
||||
nostr_pubkey=keypair.pubkey,
|
||||
nostr_auth_tag=compute_auth_tag(
|
||||
self._config.owner_secret_key, keypair.pubkey
|
||||
),
|
||||
llm_endpoint=entry.endpoint,
|
||||
llm_api_key=api_key,
|
||||
)
|
||||
)
|
||||
return tuple(credentials)
|
||||
|
||||
def _mint_user(self) -> AgentCredential:
|
||||
"""Mint the trial's user identity — the human analogue, not an agent.
|
||||
|
||||
With a pinned ``user_secret_key`` the same identity fronts every
|
||||
trial, like one human running many teams; otherwise each trial gets
|
||||
a fresh user key.
|
||||
"""
|
||||
keypair = (
|
||||
keypair_from_secret(self._config.user_secret_key)
|
||||
if self._config.user_secret_key
|
||||
else generate_keypair()
|
||||
)
|
||||
return AgentCredential(
|
||||
agent_id="user",
|
||||
role="user",
|
||||
nostr_secret_key=keypair.secret_key,
|
||||
nostr_pubkey=keypair.pubkey,
|
||||
nostr_auth_tag=compute_auth_tag(
|
||||
self._config.owner_secret_key, keypair.pubkey
|
||||
),
|
||||
llm_endpoint="",
|
||||
llm_api_key="",
|
||||
)
|
||||
|
||||
def _cli_for(self, credential: AgentCredential) -> BuzzCli:
|
||||
return BuzzCli(
|
||||
relay_url=self._config.relay_http_url,
|
||||
secret_key=credential.nostr_secret_key,
|
||||
auth_tag=credential.nostr_auth_tag,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _lock_trial(conn: psycopg.Connection, run_id: str, trial_id: str) -> None:
|
||||
digest = hashlib.sha256(f"{run_id}\x00{trial_id}".encode()).digest()
|
||||
lock_key = int.from_bytes(digest[:8], "big", signed=True)
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (lock_key,))
|
||||
|
||||
@staticmethod
|
||||
def _load_trial(
|
||||
conn: psycopg.Connection, run_id: str, trial_id: str
|
||||
) -> TrialHandle | None:
|
||||
row = conn.execute(
|
||||
"SELECT handle FROM benchmark.trial_manifest"
|
||||
" WHERE run_id = %s AND trial_id = %s",
|
||||
(run_id, trial_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
stored = row[0]
|
||||
return TrialHandle(
|
||||
run_id=stored["run_id"],
|
||||
trial_id=stored["trial_id"],
|
||||
manifest_hash=stored["manifest_hash"],
|
||||
relay_ws_url=stored["relay_ws_url"],
|
||||
channel_id=stored["channel_id"],
|
||||
credentials=tuple(
|
||||
AgentCredential(**credential) for credential in stored["credentials"]
|
||||
),
|
||||
user=AgentCredential(**stored["user"]),
|
||||
user_relay_url=stored.get("user_relay_url", ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _store_trial(conn: psycopg.Connection, handle: TrialHandle) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO benchmark.trial_manifest"
|
||||
" (run_id, trial_id, manifest_hash, channel_id, handle)"
|
||||
" VALUES (%s, %s, %s, %s, %s)",
|
||||
(
|
||||
handle.run_id,
|
||||
handle.trial_id,
|
||||
handle.manifest_hash,
|
||||
handle.channel_id,
|
||||
json.dumps(dataclasses.asdict(handle)),
|
||||
),
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Shared fixtures for testbed provisioning tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from harbor_buzz_orchestra.manifest import ExperimentManifest
|
||||
|
||||
PROMPT_REF = {"path": "personas/x.md", "sha256": "0" * 64}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def manifest() -> ExperimentManifest:
|
||||
"""A minimal two-class manifest: one orchestrator, two workers."""
|
||||
return ExperimentManifest.load(
|
||||
{
|
||||
"condition": "O-test",
|
||||
"roster": [
|
||||
{
|
||||
"id": "worker-glm",
|
||||
"kind": "worker",
|
||||
"role": "implementer",
|
||||
"count": 2,
|
||||
"endpoint": "databricks/glm",
|
||||
"model_revision": "glm-5.2",
|
||||
"prompt": PROMPT_REF,
|
||||
"generation": {
|
||||
"max_output_tokens": 4096,
|
||||
"context_window_tokens": 128000,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "orch-opus",
|
||||
"kind": "orchestrator",
|
||||
"role": "lead",
|
||||
"count": 1,
|
||||
"endpoint": "databricks/opus",
|
||||
"model_revision": "opus-4.8",
|
||||
"prompt": PROMPT_REF,
|
||||
"generation": {
|
||||
"max_output_tokens": 8192,
|
||||
"context_window_tokens": 200000,
|
||||
},
|
||||
},
|
||||
],
|
||||
"prices": {
|
||||
"databricks/glm": {
|
||||
"input_per_million_usd": 0.5,
|
||||
"cached_input_per_million_usd": 0.1,
|
||||
"output_per_million_usd": 1.5,
|
||||
},
|
||||
"databricks/opus": {
|
||||
"input_per_million_usd": 15.0,
|
||||
"cached_input_per_million_usd": 1.5,
|
||||
"output_per_million_usd": 75.0,
|
||||
},
|
||||
},
|
||||
"trial_budget": {"timeout_seconds": 3600},
|
||||
}
|
||||
)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""just benchmark must default to leaderboard-eligible settings."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPT = Path(__file__).parents[2] / "scripts" / "benchmark.py"
|
||||
_spec = importlib.util.spec_from_file_location("benchmark", _SCRIPT)
|
||||
benchmark = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["benchmark"] = benchmark
|
||||
_spec.loader.exec_module(benchmark)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(benchmark, "STATE_DIR", tmp_path / ".benchmark")
|
||||
return tmp_path / ".benchmark"
|
||||
|
||||
|
||||
def test_defaults_are_leaderboard_eligible():
|
||||
args = benchmark.parse_args([])
|
||||
assert args.attempts == 5
|
||||
assert args.dataset is None and args.path is None # dataset default applied later
|
||||
argv = benchmark.leaderboard_argv(args, Path("prov.json"), Path("linux-bin"))
|
||||
assert argv[argv.index("--dataset") + 1] == "terminal-bench/terminal-bench-2-1"
|
||||
assert argv[argv.index("--attempts") + 1] == "5"
|
||||
assert argv[argv.index("--manifest") + 1].endswith("tb-cobol-sonnet-haiku.yaml")
|
||||
assert argv[argv.index("--agent-bin-dir") + 1] == "linux-bin"
|
||||
# In-container agents reach the host relay through the forwarder gateway.
|
||||
assert argv[argv.index("--relay-gateway") + 1] == (
|
||||
f"host.docker.internal:{benchmark.RELAY_HTTP_PORT}"
|
||||
)
|
||||
|
||||
|
||||
def test_selectors_pass_through():
|
||||
args = benchmark.parse_args(
|
||||
["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1",
|
||||
"--job-name", "smoke", "--dry-run"]
|
||||
)
|
||||
argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b"))
|
||||
assert argv[argv.index("--path") + 1] == "/tmp/task"
|
||||
assert argv[argv.index("--include-task") + 1] == "cobol*"
|
||||
assert argv[argv.index("--exclude-task") + 1] == "flaky*"
|
||||
assert argv[argv.index("--attempts") + 1] == "1"
|
||||
assert "--dry-run" in argv
|
||||
assert "--dataset" not in argv
|
||||
|
||||
|
||||
def test_state_is_generated_once_and_reused(state_dir):
|
||||
first = benchmark.load_state()
|
||||
second = benchmark.load_state()
|
||||
assert first["user_secret_key"] == second["user_secret_key"]
|
||||
assert first["owner_secret_key"] != first["user_secret_key"]
|
||||
assert len(first["user_pubkey"]) == 64
|
||||
stored = json.loads((state_dir / "state.json").read_text())
|
||||
assert "user_pubkey" not in stored # derived, never persisted
|
||||
|
||||
|
||||
def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FAKE_KEY_ENV", "sk-test")
|
||||
endpoints = tmp_path / "endpoints.json"
|
||||
endpoints.write_text(
|
||||
json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}})
|
||||
)
|
||||
state = benchmark.load_state()
|
||||
path = benchmark.write_provisioner_config(state, endpoints)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["user_secret_key"] == state["user_secret_key"]
|
||||
assert config["archive_on_teardown"] is False
|
||||
assert config["llm_api_keys"] == {"model-a": "sk-test"}
|
||||
assert str(benchmark.RELAY_HTTP_PORT) in config["relay_http_url"]
|
||||
# Both views dial the relay's canonical host-bound address; inside the
|
||||
# task container the loopback forwarder bridges it to the host gateway.
|
||||
assert config["relay_ws_url"] == f"ws://localhost:{benchmark.RELAY_HTTP_PORT}"
|
||||
assert config["relay_http_url"].startswith("http://localhost:")
|
||||
|
||||
|
||||
def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MISSING_KEY_ENV", raising=False)
|
||||
endpoints = tmp_path / "endpoints.json"
|
||||
endpoints.write_text(
|
||||
json.dumps({"model-a": {"provider": "x", "api_key_env": "MISSING_KEY_ENV"}})
|
||||
)
|
||||
with pytest.raises(SystemExit, match="MISSING_KEY_ENV"):
|
||||
benchmark.write_provisioner_config(benchmark.load_state(), endpoints)
|
||||
|
||||
|
||||
def test_env_file_wires_owner_and_ports(state_dir):
|
||||
state = benchmark.load_state()
|
||||
env_path = benchmark.write_env_file(state)
|
||||
env = dict(
|
||||
line.split("=", 1) for line in env_path.read_text().splitlines() if line
|
||||
)
|
||||
assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"]
|
||||
assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT)
|
||||
assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT)
|
||||
assert env["BUZZ_REQUIRE_RELAY_MEMBERSHIP"] == "true"
|
||||
|
||||
|
||||
def test_compose_command_isolates_the_project(state_dir):
|
||||
command = benchmark.compose_command("up", "-d")
|
||||
assert command[:2] == ["docker", "compose"]
|
||||
assert command[command.index("--project-name") + 1] == "buzz-benchmark"
|
||||
files = [command[i + 1] for i, part in enumerate(command) if part == "-f"]
|
||||
assert any(f.endswith("deploy/compose/compose.yml") for f in files)
|
||||
assert any(f.endswith("compose.benchmark.yml") for f in files)
|
||||
|
||||
|
||||
def test_bring_up_self_heals_a_stale_credential_volume(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(command, check=True):
|
||||
calls.append(command)
|
||||
if len(calls) == 1: # first up fails against the stale volume
|
||||
raise benchmark.subprocess.CalledProcessError(1, command)
|
||||
|
||||
monkeypatch.setattr(benchmark.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: True)
|
||||
benchmark.bring_up_stack({})
|
||||
assert [c[-3:] for c in calls] == [
|
||||
["up", "-d", "--wait"],
|
||||
[str(benchmark.COMPOSE_FILES[-1]), "down", "-v"],
|
||||
["up", "-d", "--wait"],
|
||||
]
|
||||
|
||||
|
||||
def test_bring_up_reraises_unrelated_failures(monkeypatch):
|
||||
def fake_run(command, check=True):
|
||||
raise benchmark.subprocess.CalledProcessError(1, command)
|
||||
|
||||
monkeypatch.setattr(benchmark.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(benchmark, "stale_credential_volume", lambda state: False)
|
||||
with pytest.raises(benchmark.subprocess.CalledProcessError):
|
||||
benchmark.bring_up_stack({})
|
||||
|
||||
|
||||
def test_fresh_resets_volumes_and_gui_state(tmp_path, monkeypatch):
|
||||
commands = []
|
||||
monkeypatch.setattr(
|
||||
benchmark.subprocess, "run", lambda cmd, check=True: commands.append(cmd)
|
||||
)
|
||||
monkeypatch.setattr(benchmark.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(benchmark.Path, "home", classmethod(lambda cls: tmp_path))
|
||||
gui_state = tmp_path / "Library" / "WebKit" / benchmark.GUI_BUNDLE_IDENTIFIER
|
||||
gui_state.mkdir(parents=True)
|
||||
(gui_state / "localstorage.sqlite3").touch()
|
||||
|
||||
benchmark.reset_environment()
|
||||
|
||||
assert ["down", "-v"] == commands[0][-2:]
|
||||
assert not gui_state.exists()
|
||||
assert benchmark.parse_args(["--fresh"]).fresh
|
||||
assert not benchmark.parse_args([]).fresh
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Keygen and NIP-OA attestation unit tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import coincurve
|
||||
from harbor_buzz_testbed.keys import (
|
||||
compute_auth_tag,
|
||||
encode_nsec,
|
||||
generate_keypair,
|
||||
)
|
||||
|
||||
# Produced by the Rust reference implementation
|
||||
# (crates/buzz-sdk/examples/compute_auth_tag.rs) for owner secret 0x...03 and
|
||||
# agent pubkey "a" * 64. Pins the preimage format across implementations.
|
||||
RUST_OWNER_SECRET = "0" * 63 + "3"
|
||||
RUST_AGENT_PUBKEY = "a" * 64
|
||||
RUST_TAG = [
|
||||
"auth",
|
||||
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
|
||||
"",
|
||||
"20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da"
|
||||
"34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867",
|
||||
]
|
||||
|
||||
|
||||
def preimage_digest(agent_pubkey: str, conditions: str) -> bytes:
|
||||
return hashlib.sha256(
|
||||
f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode()
|
||||
).digest()
|
||||
|
||||
|
||||
def test_generate_keypair_is_fresh_and_hex():
|
||||
first, second = generate_keypair(), generate_keypair()
|
||||
assert first.secret_key != second.secret_key
|
||||
assert first.pubkey != second.pubkey
|
||||
assert len(first.secret_key) == 64
|
||||
assert len(first.pubkey) == 64
|
||||
int(first.secret_key, 16)
|
||||
int(first.pubkey, 16)
|
||||
|
||||
|
||||
def test_auth_tag_shape_and_owner_pubkey():
|
||||
tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, RUST_AGENT_PUBKEY))
|
||||
assert tag[0] == "auth"
|
||||
assert tag[1] == RUST_TAG[1] # same owner pubkey as the Rust implementation
|
||||
assert tag[2] == ""
|
||||
|
||||
|
||||
def test_auth_tag_signature_verifies_over_nip_oa_preimage():
|
||||
agent = generate_keypair()
|
||||
tag = json.loads(compute_auth_tag(RUST_OWNER_SECRET, agent.pubkey))
|
||||
owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(tag[1]))
|
||||
assert owner_pubkey.verify(bytes.fromhex(tag[3]), preimage_digest(agent.pubkey, ""))
|
||||
|
||||
|
||||
def test_rust_reference_tag_verifies_under_python_preimage():
|
||||
"""The Rust-signed vector must verify against our preimage construction."""
|
||||
owner_pubkey = coincurve.PublicKeyXOnly(bytes.fromhex(RUST_TAG[1]))
|
||||
assert owner_pubkey.verify(
|
||||
bytes.fromhex(RUST_TAG[3]), preimage_digest(RUST_AGENT_PUBKEY, "")
|
||||
)
|
||||
|
||||
|
||||
def test_encode_nsec_matches_nip19_vector():
|
||||
# NIP-19 reference vector from the spec.
|
||||
assert (
|
||||
encode_nsec("67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa")
|
||||
== "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Live provisioning tests against a running benchmark stack.
|
||||
|
||||
Gated by BUZZ_TESTBED_LIVE=1 with stack coordinates in the environment:
|
||||
BUZZ_TESTBED_RELAY_HTTP (default http://localhost:3000)
|
||||
BUZZ_TESTBED_RELAY_WS (default ws://host.docker.internal:3000)
|
||||
BUZZ_TESTBED_OWNER_KEY relay owner secret key (hex)
|
||||
BUZZ_TESTBED_PG_DSN benchmark Postgres DSN
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError
|
||||
from harbor_buzz_testbed.provisioner import (
|
||||
BuzzTrialProvisioner,
|
||||
ProvisioningError,
|
||||
TestbedConfig,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("BUZZ_TESTBED_LIVE") != "1",
|
||||
reason="live testbed suite; set BUZZ_TESTBED_LIVE=1 with a running stack",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def provisioner() -> BuzzTrialProvisioner:
|
||||
owner_key = os.environ.get("BUZZ_TESTBED_OWNER_KEY")
|
||||
dsn = os.environ.get("BUZZ_TESTBED_PG_DSN")
|
||||
if not owner_key or not dsn:
|
||||
pytest.fail("BUZZ_TESTBED_OWNER_KEY and BUZZ_TESTBED_PG_DSN are required")
|
||||
return BuzzTrialProvisioner(
|
||||
TestbedConfig(
|
||||
relay_http_url=os.environ.get(
|
||||
"BUZZ_TESTBED_RELAY_HTTP", "http://localhost:3000"
|
||||
),
|
||||
relay_ws_url=os.environ.get(
|
||||
"BUZZ_TESTBED_RELAY_WS", "ws://host.docker.internal:3000"
|
||||
),
|
||||
owner_secret_key=owner_key,
|
||||
postgres_dsn=dsn,
|
||||
llm_api_keys={
|
||||
"databricks/glm": "test-glm-key",
|
||||
"databricks/opus": "test-opus-key",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def cli_for(provisioner: BuzzTrialProvisioner, credential) -> BuzzCli:
|
||||
return provisioner._cli_for(credential)
|
||||
|
||||
|
||||
def test_create_is_idempotent_and_isolated(provisioner, manifest):
|
||||
run_id = f"live-{uuid.uuid4().hex[:8]}"
|
||||
trial_a = str(uuid.uuid4())
|
||||
trial_b = str(uuid.uuid4())
|
||||
|
||||
provisioner.healthcheck()
|
||||
handle_a = provisioner.create_trial(run_id, trial_a, manifest)
|
||||
handle_b = provisioner.create_trial(run_id, trial_b, manifest)
|
||||
try:
|
||||
# Shape: one credential per roster slot, orchestrator first.
|
||||
assert len(handle_a.credentials) == 3
|
||||
assert handle_a.credentials[0].role == "orchestrator"
|
||||
assert handle_a.manifest_hash == manifest.sha256
|
||||
|
||||
# Idempotency: same key returns the stored handle, keys included.
|
||||
again = provisioner.create_trial(run_id, trial_a, manifest)
|
||||
assert again == handle_a
|
||||
|
||||
# Isolation: trials get distinct channels and fresh keys...
|
||||
assert handle_a.channel_id != handle_b.channel_id
|
||||
keys_a = {c.nostr_secret_key for c in handle_a.credentials}
|
||||
keys_b = {c.nostr_secret_key for c in handle_b.credentials}
|
||||
assert not keys_a & keys_b
|
||||
|
||||
# ...and trial B's orchestrator cannot see trial A's channel.
|
||||
cli_a = cli_for(provisioner, handle_a.credentials[0])
|
||||
cli_b = cli_for(provisioner, handle_b.credentials[0])
|
||||
cli_a.run(
|
||||
"messages",
|
||||
"send",
|
||||
"--channel",
|
||||
handle_a.channel_id,
|
||||
"--content",
|
||||
"trial A secret",
|
||||
)
|
||||
foreign_read = cli_b.run(
|
||||
"messages", "get", "--channel", handle_a.channel_id, "--limit", "10"
|
||||
)
|
||||
assert foreign_read == [], "cross-trial read must return nothing"
|
||||
with pytest.raises(BuzzCliError, match="private"):
|
||||
cli_b.run("channels", "join", "--channel", handle_a.channel_id)
|
||||
|
||||
# Members can read their own channel.
|
||||
own_read = cli_a.run(
|
||||
"messages", "get", "--channel", handle_a.channel_id, "--limit", "10"
|
||||
)
|
||||
assert [m["content"] for m in own_read] == ["trial A secret"]
|
||||
|
||||
# Same trial key + different manifest must be rejected, not silently
|
||||
# reprovisioned.
|
||||
altered = manifest.model_copy(update={"condition": "O-altered"})
|
||||
with pytest.raises(ProvisioningError, match="already provisioned"):
|
||||
provisioner.create_trial(run_id, trial_a, altered)
|
||||
finally:
|
||||
provisioner.teardown(handle_a)
|
||||
provisioner.teardown(handle_b)
|
||||
|
||||
# Teardown is idempotent and stamps archived_at.
|
||||
provisioner.teardown(handle_a)
|
||||
with psycopg.connect(os.environ["BUZZ_TESTBED_PG_DSN"]) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT archived_at FROM benchmark.trial_manifest"
|
||||
" WHERE run_id = %s AND trial_id = %s",
|
||||
(run_id, trial_a),
|
||||
).fetchone()
|
||||
assert row is not None and row[0] is not None
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Provisioner unit tests — no relay or Postgres required."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import coincurve
|
||||
import pytest
|
||||
from harbor_buzz_testbed.provisioner import (
|
||||
BuzzTrialProvisioner,
|
||||
ProvisioningError,
|
||||
TestbedConfig,
|
||||
)
|
||||
|
||||
OWNER_SECRET = "0" * 63 + "3"
|
||||
|
||||
|
||||
def config(**overrides) -> TestbedConfig:
|
||||
defaults = dict(
|
||||
relay_http_url="http://localhost:3000",
|
||||
relay_ws_url="ws://host.docker.internal:3000",
|
||||
owner_secret_key=OWNER_SECRET,
|
||||
postgres_dsn="postgresql://unused",
|
||||
llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"},
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return TestbedConfig(**defaults)
|
||||
|
||||
|
||||
def test_mint_credentials_expands_roster(manifest):
|
||||
credentials = BuzzTrialProvisioner(config())._mint_credentials(manifest)
|
||||
assert [c.agent_id for c in credentials] == [
|
||||
"orch-opus-1",
|
||||
"worker-glm-1",
|
||||
"worker-glm-2",
|
||||
]
|
||||
assert credentials[0].role == "orchestrator"
|
||||
assert credentials[0].llm_api_key == "opus-key"
|
||||
assert {c.llm_api_key for c in credentials[1:]} == {"glm-key"}
|
||||
|
||||
|
||||
def test_mint_credentials_keys_are_fresh_and_attested(manifest):
|
||||
provisioner = BuzzTrialProvisioner(config())
|
||||
first = provisioner._mint_credentials(manifest)
|
||||
second = provisioner._mint_credentials(manifest)
|
||||
all_secrets = [c.nostr_secret_key for c in first + second]
|
||||
assert len(all_secrets) == len(set(all_secrets)), "keys must never be reused"
|
||||
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
|
||||
for credential in first:
|
||||
tag = json.loads(credential.nostr_auth_tag)
|
||||
assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""]
|
||||
digest = hashlib.sha256(
|
||||
f"nostr:agent-auth:{credential.nostr_pubkey}:".encode()
|
||||
).digest()
|
||||
assert owner_pubkey.verify(bytes.fromhex(tag[3]), digest)
|
||||
|
||||
|
||||
def test_mint_user_is_attested_and_not_an_agent():
|
||||
provisioner = BuzzTrialProvisioner(config())
|
||||
user = provisioner._mint_user()
|
||||
assert user.agent_id == "user"
|
||||
assert user.role == "user"
|
||||
assert user.llm_endpoint == "" and user.llm_api_key == ""
|
||||
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
|
||||
tag = json.loads(user.nostr_auth_tag)
|
||||
assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""]
|
||||
|
||||
|
||||
def test_pinned_user_secret_reuses_one_identity():
|
||||
pinned = "7" * 64
|
||||
provisioner = BuzzTrialProvisioner(config(user_secret_key=pinned))
|
||||
first = provisioner._mint_user()
|
||||
second = provisioner._mint_user()
|
||||
assert first.nostr_secret_key == pinned
|
||||
assert first.nostr_pubkey == second.nostr_pubkey
|
||||
expected = coincurve.PrivateKey(bytes.fromhex(pinned)).public_key_xonly
|
||||
assert first.nostr_pubkey == expected.format().hex()
|
||||
# Still a user, still attested by the owner.
|
||||
assert first.role == "user"
|
||||
owner_pubkey = coincurve.PrivateKey(bytes.fromhex(OWNER_SECRET)).public_key_xonly
|
||||
assert json.loads(first.nostr_auth_tag)[1] == owner_pubkey.format().hex()
|
||||
|
||||
|
||||
def test_teardown_skips_archiving_when_disabled():
|
||||
provisioner = BuzzTrialProvisioner(config(archive_on_teardown=False))
|
||||
# Any attribute access would fail on this handle — teardown must return
|
||||
# before touching the CLI or Postgres.
|
||||
provisioner.teardown(handle=None)
|
||||
|
||||
|
||||
def test_mint_credentials_missing_api_key_is_explicit(manifest):
|
||||
provisioner = BuzzTrialProvisioner(config(llm_api_keys={}))
|
||||
with pytest.raises(ProvisioningError, match="databricks/"):
|
||||
provisioner._mint_credentials(manifest)
|
||||
|
||||
|
||||
def test_lock_key_is_deterministic_and_distinct():
|
||||
calls: list[int] = []
|
||||
|
||||
class FakeConn:
|
||||
def execute(self, _query, params):
|
||||
calls.append(params[0])
|
||||
|
||||
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1")
|
||||
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-1")
|
||||
BuzzTrialProvisioner._lock_trial(FakeConn(), "run-a", "trial-2")
|
||||
assert calls[0] == calls[1]
|
||||
assert calls[0] != calls[2]
|
||||
assert all(-(2**63) <= key < 2**63 for key in calls)
|
||||
|
||||
|
||||
def test_healthcheck_fails_fast_when_relay_down():
|
||||
provisioner = BuzzTrialProvisioner(
|
||||
config(relay_http_url="http://localhost:1", postgres_dsn="postgresql://unused")
|
||||
)
|
||||
with pytest.raises(ProvisioningError, match="relay unreachable"):
|
||||
provisioner.healthcheck()
|
||||
2588
benchmarks/harbor-buzz-orchestra/testbed/uv.lock
generated
2588
benchmarks/harbor-buzz-orchestra/testbed/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
from typing import Any
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manifest_data() -> dict[str, Any]:
|
||||
digest = "a" * 64
|
||||
generation = {
|
||||
"temperature": 0,
|
||||
"max_output_tokens": 4096,
|
||||
"context_window_tokens": 200000,
|
||||
}
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"condition": "O",
|
||||
"roster": [
|
||||
{
|
||||
"id": "opus-orchestrator",
|
||||
"kind": "orchestrator",
|
||||
"role": "orchestrator",
|
||||
"count": 1,
|
||||
"endpoint": "databricks/opus",
|
||||
"model_revision": "2026-07-01",
|
||||
"prompt": {"path": "prompts/orchestrator.md", "sha256": digest},
|
||||
"generation": generation,
|
||||
},
|
||||
{
|
||||
"id": "qwen-workers",
|
||||
"kind": "worker",
|
||||
"role": "fast-worker",
|
||||
"count": 4,
|
||||
"concurrency": 2,
|
||||
"endpoint": "databricks/qwen",
|
||||
"model_revision": "revision-42",
|
||||
"prompt": {"path": "prompts/qwen.md", "sha256": digest},
|
||||
"generation": {**generation, "context_window_tokens": 32768},
|
||||
},
|
||||
],
|
||||
"prices": {
|
||||
"databricks/opus": {
|
||||
"input_per_million_usd": 5,
|
||||
"cached_input_per_million_usd": 0.5,
|
||||
"output_per_million_usd": 25,
|
||||
},
|
||||
"databricks/qwen": {
|
||||
"input_per_million_usd": 0.1,
|
||||
"cached_input_per_million_usd": 0.01,
|
||||
"output_per_million_usd": 0.2,
|
||||
},
|
||||
},
|
||||
"trial_budget": {"timeout_seconds": 3600, "max_cost_usd": 5},
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
from harbor.models.agent.context import AgentContext
|
||||
from harbor_buzz_orchestra import (
|
||||
AgentCredential,
|
||||
BuzzOrchestraAgent,
|
||||
RuntimeResult,
|
||||
TrialHandle,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_agent_credential_carries_closed_relay_attestation():
|
||||
credential = AgentCredential(
|
||||
agent_id="orchestrator-1",
|
||||
role="orchestrator",
|
||||
nostr_secret_key="11" * 32,
|
||||
nostr_pubkey="22" * 32,
|
||||
nostr_auth_tag='["auth","owner","conditions","signature"]',
|
||||
llm_endpoint="https://example.databricks.com/serving-endpoints/opus",
|
||||
llm_api_key="attributed-key",
|
||||
)
|
||||
|
||||
assert credential.nostr_auth_tag.startswith('["auth"')
|
||||
|
||||
|
||||
class Provisioner:
|
||||
def __init__(self):
|
||||
self.healthchecked = False
|
||||
self.created = None
|
||||
self.torn_down = None
|
||||
|
||||
def healthcheck(self):
|
||||
self.healthchecked = True
|
||||
|
||||
def create_trial(self, run_id, trial_id, manifest, channel_label=None):
|
||||
self.created = (run_id, trial_id, manifest, channel_label)
|
||||
return TrialHandle(
|
||||
run_id,
|
||||
trial_id,
|
||||
manifest.sha256,
|
||||
"ws://relay",
|
||||
"channel-1",
|
||||
(),
|
||||
user=AgentCredential(
|
||||
agent_id="user",
|
||||
role="user",
|
||||
nostr_secret_key="s",
|
||||
nostr_pubkey="p",
|
||||
nostr_auth_tag="[]",
|
||||
llm_endpoint="",
|
||||
llm_api_key="",
|
||||
),
|
||||
)
|
||||
|
||||
def teardown(self, handle):
|
||||
self.torn_down = handle
|
||||
|
||||
|
||||
class Runtime:
|
||||
def __init__(self, error=None):
|
||||
self.called = None
|
||||
self.error = error
|
||||
|
||||
async def run(self, **kwargs):
|
||||
self.called = kwargs
|
||||
if self.error:
|
||||
raise self.error
|
||||
return RuntimeResult(10, 2, 3, 0.25, {"receipt_status": "pending"})
|
||||
|
||||
|
||||
async def test_agent_lifecycle_and_context(tmp_path, manifest_data):
|
||||
provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4()
|
||||
environment = SimpleNamespace(
|
||||
context_id=context_id, environment_name="hello-world"
|
||||
)
|
||||
agent = BuzzOrchestraAgent(
|
||||
logs_dir=tmp_path,
|
||||
manifest=manifest_data,
|
||||
provisioner=provisioner,
|
||||
runtime=runtime,
|
||||
run_id="run-1",
|
||||
)
|
||||
agent.context_id = context_id
|
||||
context = AgentContext()
|
||||
await agent.setup(environment)
|
||||
await agent.run("solve it", environment, context)
|
||||
assert provisioner.healthchecked
|
||||
assert provisioner.created[:2] == ("run-1", str(context_id))
|
||||
# The task short name labels the trial channel for spectator GUIs.
|
||||
assert provisioner.created[3] == "hello-world"
|
||||
assert provisioner.torn_down.channel_id == "channel-1"
|
||||
assert runtime.called["instruction"] == "solve it"
|
||||
assert (
|
||||
context.n_input_tokens,
|
||||
context.n_cache_tokens,
|
||||
context.n_output_tokens,
|
||||
context.cost_usd,
|
||||
) == (10, 2, 3, 0.25)
|
||||
assert context.metadata["manifest_sha256"] == agent.manifest.sha256
|
||||
assert context.metadata["trial_id"] == str(context_id)
|
||||
|
||||
|
||||
async def test_teardown_runs_when_runtime_fails(tmp_path, manifest_data):
|
||||
provisioner, runtime, context_id = (
|
||||
Provisioner(),
|
||||
Runtime(RuntimeError("runtime failed")),
|
||||
uuid4(),
|
||||
)
|
||||
environment = SimpleNamespace(context_id=context_id)
|
||||
agent = BuzzOrchestraAgent(
|
||||
logs_dir=tmp_path,
|
||||
manifest=manifest_data,
|
||||
provisioner=provisioner,
|
||||
runtime=runtime,
|
||||
)
|
||||
agent.context_id = context_id
|
||||
with pytest.raises(RuntimeError, match="runtime failed"):
|
||||
await agent.run("solve it", environment, AgentContext())
|
||||
assert provisioner.torn_down.channel_id == "channel-1"
|
||||
|
||||
|
||||
async def test_missing_integrations_fail_explicitly(tmp_path, manifest_data):
|
||||
agent = BuzzOrchestraAgent(logs_dir=tmp_path, manifest=manifest_data)
|
||||
with pytest.raises(RuntimeError, match="M1 wiring is incomplete"):
|
||||
await agent.run("solve it", SimpleNamespace(context_id=uuid4()), AgentContext())
|
||||
|
||||
|
||||
async def test_cli_runtime_construction_from_json(tmp_path, manifest_data):
|
||||
endpoint_path = tmp_path / "endpoints.json"
|
||||
endpoint_path.write_text(
|
||||
'{"frontier/rev":{"provider":"anthropic",'
|
||||
'"api_key_env":"ANTHROPIC_API_KEY"},'
|
||||
'"worker/rev":{"provider":"openai",'
|
||||
'"api_key_env":"OPENAI_API_KEY"}}'
|
||||
)
|
||||
agent = BuzzOrchestraAgent(
|
||||
logs_dir=tmp_path / "logs",
|
||||
manifest=manifest_data,
|
||||
artifact_root=tmp_path,
|
||||
endpoint_config=endpoint_path,
|
||||
buzz_acp_binary="/pinned/buzz-acp",
|
||||
buzz_agent_binary="/pinned/buzz-agent",
|
||||
buzz_dev_mcp_binary="/pinned/buzz-dev-mcp",
|
||||
buzz_cli_binary="/pinned/buzz",
|
||||
)
|
||||
assert agent.runtime.artifact_root == tmp_path
|
||||
assert agent.runtime.endpoints["frontier/rev"].provider == "anthropic"
|
||||
assert agent.runtime.buzz_acp_binary == "/pinned/buzz-acp"
|
||||
assert agent.runtime.buzz_agent_binary == "/pinned/buzz-agent"
|
||||
assert agent.runtime.buzz_dev_mcp_binary == "/pinned/buzz-dev-mcp"
|
||||
assert agent.runtime.buzz_cli_binary == "/pinned/buzz"
|
||||
|
||||
|
||||
async def test_cli_construction_requires_complete_pairs(tmp_path, manifest_data):
|
||||
with pytest.raises(ValueError, match="artifact_root"):
|
||||
BuzzOrchestraAgent(
|
||||
logs_dir=tmp_path,
|
||||
manifest=manifest_data,
|
||||
endpoint_config={},
|
||||
)
|
||||
with pytest.raises(ValueError, match="provisioner_factory"):
|
||||
BuzzOrchestraAgent(
|
||||
logs_dir=tmp_path,
|
||||
manifest=manifest_data,
|
||||
provisioner_config={},
|
||||
)
|
||||
@@ -1,426 +0,0 @@
|
||||
"""The container runtime must launch the production stack, unmodified."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from harbor.environments.base import ExecResult
|
||||
|
||||
from harbor_buzz_orchestra.manifest import ExperimentManifest
|
||||
from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
|
||||
from harbor_buzz_orchestra.container_runtime import (
|
||||
REMOTE_BIN,
|
||||
REMOTE_LOGS,
|
||||
BuzzContainerRuntime,
|
||||
EndpointLaunchConfig,
|
||||
RuntimeLaunchError,
|
||||
)
|
||||
|
||||
|
||||
def write_manifest(tmp_path: Path) -> ExperimentManifest:
|
||||
prompt = tmp_path / "prompt.md"
|
||||
prompt.write_text("prompt", encoding="utf-8")
|
||||
digest = hashlib.sha256(prompt.read_bytes()).hexdigest()
|
||||
roster_entry = {
|
||||
"count": 1,
|
||||
"model_revision": "r1",
|
||||
"prompt": {"path": "prompt.md", "sha256": digest},
|
||||
"generation": {"max_output_tokens": 100, "context_window_tokens": 1000},
|
||||
}
|
||||
return ExperimentManifest.load(
|
||||
{
|
||||
"condition": "test",
|
||||
"roster": [
|
||||
{"id": "orch", "kind": "orchestrator", "role": "lead",
|
||||
"endpoint": "orch-model", **roster_entry},
|
||||
{"id": "worker", "kind": "worker", "role": "implementer",
|
||||
"endpoint": "worker-model", **roster_entry},
|
||||
],
|
||||
"prices": {
|
||||
name: {
|
||||
"input_per_million_usd": 0,
|
||||
"cached_input_per_million_usd": 0,
|
||||
"output_per_million_usd": 0,
|
||||
}
|
||||
for name in ("orch-model", "worker-model")
|
||||
},
|
||||
"trial_budget": {"timeout_seconds": 30},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def credential(agent_id, role, endpoint):
|
||||
return AgentCredential(
|
||||
agent_id=agent_id,
|
||||
role=role,
|
||||
nostr_secret_key=f"secret-{agent_id}",
|
||||
nostr_pubkey=f"pubkey-{agent_id}",
|
||||
nostr_auth_tag="[]",
|
||||
llm_endpoint=endpoint,
|
||||
llm_api_key=f"key-{agent_id}",
|
||||
)
|
||||
|
||||
|
||||
def user_credential():
|
||||
return AgentCredential(
|
||||
agent_id="user",
|
||||
role="user",
|
||||
nostr_secret_key="secret-user",
|
||||
nostr_pubkey="pubkey-user",
|
||||
nostr_auth_tag="[]",
|
||||
llm_endpoint="",
|
||||
llm_api_key="",
|
||||
)
|
||||
|
||||
|
||||
def trial_handle(credentials, user_relay_url=""):
|
||||
return TrialHandle(
|
||||
run_id="run",
|
||||
trial_id="trial",
|
||||
manifest_hash="hash",
|
||||
relay_ws_url="ws://host.docker.internal:3600",
|
||||
channel_id="channel",
|
||||
credentials=credentials,
|
||||
user=user_credential(),
|
||||
user_relay_url=user_relay_url,
|
||||
)
|
||||
|
||||
|
||||
def runtime(tmp_path, **kwargs):
|
||||
return BuzzContainerRuntime(
|
||||
logs_dir=tmp_path / "logs",
|
||||
artifact_root=tmp_path,
|
||||
endpoints={
|
||||
"orch-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"),
|
||||
"worker-model": EndpointLaunchConfig("anthropic", "ANTHROPIC_API_KEY"),
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Environment:
|
||||
"""Records execs/uploads; scripted stdout per command substring."""
|
||||
|
||||
def __init__(self, responses=None):
|
||||
self.commands = []
|
||||
self.uploads = []
|
||||
self.responses = responses or {}
|
||||
|
||||
async def exec(self, command, env=None, **kwargs):
|
||||
self.commands.append((command, env))
|
||||
for needle, result in self.responses.items():
|
||||
if needle in command:
|
||||
return result
|
||||
return ExecResult(stdout="", stderr="", return_code=0)
|
||||
|
||||
async def upload_file(self, source, target):
|
||||
self.uploads.append((str(source), target))
|
||||
|
||||
async def download_dir(self, source, target):
|
||||
pass
|
||||
|
||||
|
||||
def test_maps_credentials_exactly_and_rejects_role_mismatch(tmp_path):
|
||||
manifest = write_manifest(tmp_path)
|
||||
credentials = (
|
||||
credential("orch-1", "orchestrator", "orch-model"),
|
||||
credential("worker-1", "worker", "worker-model"),
|
||||
)
|
||||
assert set(runtime(tmp_path)._classes_by_agent_id(manifest, credentials)) == {
|
||||
"orch-1",
|
||||
"worker-1",
|
||||
}
|
||||
bad = (credential("worker-1", "orchestrator", "worker-model"),)
|
||||
with pytest.raises(RuntimeLaunchError, match="role"):
|
||||
runtime(tmp_path)._classes_by_agent_id(manifest, bad)
|
||||
|
||||
|
||||
def test_prompt_hash_and_identity_override_are_fail_closed(tmp_path):
|
||||
manifest = write_manifest(tmp_path)
|
||||
prompt_ref = manifest.roster[0].prompt
|
||||
runtime(tmp_path)._verify_artifact(tmp_path / prompt_ref.path, prompt_ref.sha256)
|
||||
(tmp_path / prompt_ref.path).write_text("changed", encoding="utf-8")
|
||||
with pytest.raises(RuntimeLaunchError, match="hash mismatch"):
|
||||
runtime(tmp_path)._verify_artifact(
|
||||
tmp_path / prompt_ref.path, prompt_ref.sha256
|
||||
)
|
||||
|
||||
endpoint = EndpointLaunchConfig(
|
||||
"anthropic", "ANTHROPIC_API_KEY", {"BUZZ_ACP_MCP_COMMAND": "evil"}
|
||||
)
|
||||
with pytest.raises(RuntimeLaunchError, match="identity"):
|
||||
runtime(tmp_path)._reject_identity_overrides(endpoint)
|
||||
|
||||
|
||||
def test_user_relay_url_prefers_host_view(tmp_path):
|
||||
rt = runtime(tmp_path)
|
||||
# v1.2 handles carry the host view for the trial user explicitly.
|
||||
assert (
|
||||
rt._user_relay_url(trial_handle((), user_relay_url="http://localhost:3600"))
|
||||
== "http://localhost:3600"
|
||||
)
|
||||
# pre-v1.2 handles fall back to deriving http from the agents' ws view.
|
||||
assert (
|
||||
rt._user_relay_url(trial_handle(()))
|
||||
== "http://host.docker.internal:3600"
|
||||
)
|
||||
with pytest.raises(RuntimeLaunchError, match="ws://"):
|
||||
rt._cli_relay_url("http://relay")
|
||||
|
||||
|
||||
async def test_install_stack_uploads_the_pinned_stack(tmp_path):
|
||||
binaries = {}
|
||||
for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp"):
|
||||
path = tmp_path / name
|
||||
path.write_text("#!binary")
|
||||
binaries[name] = str(path)
|
||||
rt = runtime(
|
||||
tmp_path,
|
||||
buzz_acp_binary=binaries["buzz-acp"],
|
||||
buzz_agent_binary=binaries["buzz-agent"],
|
||||
buzz_dev_mcp_binary=binaries["buzz-dev-mcp"],
|
||||
)
|
||||
environment = Environment()
|
||||
await rt._install_stack(environment)
|
||||
assert {target for _, target in environment.uploads} == {
|
||||
f"{REMOTE_BIN}/buzz-acp",
|
||||
f"{REMOTE_BIN}/buzz-agent",
|
||||
f"{REMOTE_BIN}/buzz-dev-mcp",
|
||||
}
|
||||
assert any("chmod 0755" in cmd for cmd, _ in environment.commands)
|
||||
|
||||
|
||||
async def test_install_stack_requires_binaries_on_disk(tmp_path):
|
||||
rt = runtime(tmp_path, buzz_acp_binary=str(tmp_path / "missing"))
|
||||
with pytest.raises(RuntimeLaunchError, match="binary not found"):
|
||||
await rt._install_stack(Environment())
|
||||
|
||||
|
||||
async def test_forwarder_bridges_the_canonical_relay_address(tmp_path):
|
||||
from harbor_buzz_orchestra.container_runtime import FORWARDER
|
||||
|
||||
forwarder = tmp_path / "relay-forwarder"
|
||||
forwarder.write_text("ELF")
|
||||
rt = runtime(
|
||||
tmp_path,
|
||||
relay_gateway="host.docker.internal:3600",
|
||||
forwarder_binary=str(forwarder),
|
||||
)
|
||||
trial = TrialHandle(
|
||||
run_id="run", trial_id="trial", manifest_hash="hash",
|
||||
relay_ws_url="ws://localhost:3600", channel_id="channel",
|
||||
credentials=(), user=user_credential(),
|
||||
)
|
||||
environment = Environment(
|
||||
responses={
|
||||
FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0),
|
||||
"cat ": ExecResult(
|
||||
stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600",
|
||||
stderr="", return_code=0,
|
||||
),
|
||||
}
|
||||
)
|
||||
agent = await rt._start_forwarder(environment, trial)
|
||||
assert agent is not None and agent.pid == 99
|
||||
launch = next(cmd for cmd, _ in environment.commands if FORWARDER in cmd)
|
||||
# Listens on the canonical loopback (host-header bound), targets the gateway.
|
||||
assert "127.0.0.1:3600" in launch
|
||||
assert "host.docker.internal:3600" in launch
|
||||
|
||||
# No gateway configured: the relay is reachable directly, no forwarder.
|
||||
assert await runtime(tmp_path)._start_forwarder(Environment(), trial) is None
|
||||
with pytest.raises(RuntimeLaunchError, match="ws://"):
|
||||
rt._ws_authority("http://relay")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("configured", "expected"), [(None, "32"), (7, "7")])
|
||||
async def test_launch_wires_the_desktop_environment(tmp_path, configured, expected):
|
||||
manifest = write_manifest(tmp_path)
|
||||
agent_class = manifest.roster[0]
|
||||
if configured is not None:
|
||||
agent_class = agent_class.model_copy(
|
||||
update={
|
||||
"budget": agent_class.budget.model_copy(
|
||||
update={"max_calls": configured}
|
||||
)
|
||||
}
|
||||
)
|
||||
orch = credential("orch-1", "orchestrator", "orch-model")
|
||||
trial = trial_handle((orch,))
|
||||
environment = Environment(
|
||||
responses={"buzz-acp": ExecResult(stdout="4242\n", stderr="", return_code=0)}
|
||||
)
|
||||
agent = await runtime(tmp_path)._launch_agent(
|
||||
environment=environment,
|
||||
trial=trial,
|
||||
credential=orch,
|
||||
agent_class=agent_class,
|
||||
trial_dir=tmp_path,
|
||||
)
|
||||
assert agent.pid == 4242
|
||||
command, env = environment.commands[-1]
|
||||
assert f"{REMOTE_BIN}/buzz-acp" in command
|
||||
# The real product wiring: acp spawns buzz-agent, which gets buzz-dev-mcp.
|
||||
assert env["BUZZ_ACP_AGENT_COMMAND"] == f"{REMOTE_BIN}/buzz-agent"
|
||||
assert env["BUZZ_ACP_MCP_COMMAND"] == f"{REMOTE_BIN}/buzz-dev-mcp"
|
||||
assert env["BUZZ_RELAY_URL"] == trial.relay_ws_url
|
||||
assert env["BUZZ_PRIVATE_KEY"] == orch.nostr_secret_key
|
||||
assert env["NOSTR_PRIVATE_KEY"] == orch.nostr_secret_key
|
||||
assert env["BUZZ_AGENT_NO_HINTS"] == "1"
|
||||
assert env["BUZZ_AGENT_MAX_ROUNDS"] == expected
|
||||
assert env["BUZZ_ACP_SYSTEM_PROMPT_FILE"].endswith("orch-1.system-prompt.md")
|
||||
# The composed prompt was uploaded into the container.
|
||||
assert any(
|
||||
target == env["BUZZ_ACP_SYSTEM_PROMPT_FILE"]
|
||||
for _, target in environment.uploads
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_rejects_unbounded_agent_rounds(tmp_path):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
runtime(tmp_path, max_agent_rounds=0)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
runtime(tmp_path, readiness_timeout_seconds=0)
|
||||
|
||||
|
||||
async def test_wait_for_agents_ready_requires_every_channel_subscription(tmp_path):
|
||||
rt = runtime(tmp_path, poll_seconds=0)
|
||||
logs = {"orch-1": "", "worker-1": ""}
|
||||
|
||||
class ReadyEnvironment(Environment):
|
||||
polls = 0
|
||||
|
||||
async def exec(self, command, env=None, **kwargs):
|
||||
if command.startswith("cat "):
|
||||
agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1)
|
||||
return ExecResult(
|
||||
stdout=logs[agent_id], stderr="", return_code=0
|
||||
)
|
||||
return ExecResult(stdout="", stderr="", return_code=0)
|
||||
|
||||
from harbor_buzz_orchestra.container_runtime import _Agent
|
||||
|
||||
agents = [
|
||||
_Agent(
|
||||
credential(agent_id, "worker", "worker-model"),
|
||||
pid=1,
|
||||
stdout_log=f"{REMOTE_LOGS}/{agent_id}.stdout.log",
|
||||
stderr_log=f"{REMOTE_LOGS}/{agent_id}.stderr.log",
|
||||
)
|
||||
for agent_id in logs
|
||||
]
|
||||
logs["orch-1"] = "subscribed to channel trial-channel\n"
|
||||
logs["worker-1"] = "subscribed to channel trial-channel\n"
|
||||
await rt._wait_for_agents_ready(ReadyEnvironment(), agents, "trial-channel")
|
||||
|
||||
logs["worker-1"] = ""
|
||||
rt_timeout = runtime(tmp_path, poll_seconds=0, readiness_timeout_seconds=0.01)
|
||||
with pytest.raises(RuntimeLaunchError, match="worker-1"):
|
||||
await rt_timeout._wait_for_agents_ready(
|
||||
ReadyEnvironment(), agents, "trial-channel"
|
||||
)
|
||||
|
||||
|
||||
async def test_dead_agent_processes_fail_the_trial(tmp_path):
|
||||
from harbor_buzz_orchestra.container_runtime import _Agent
|
||||
|
||||
agents = [
|
||||
_Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")
|
||||
]
|
||||
environment = Environment(
|
||||
responses={
|
||||
"kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0)
|
||||
}
|
||||
)
|
||||
with pytest.raises(RuntimeLaunchError, match="worker-1"):
|
||||
await runtime(tmp_path)._raise_for_dead_agents(environment, agents)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "return_code", "raises"),
|
||||
[
|
||||
("M1-hello-world", 0, False),
|
||||
("M1-hello-world", 1, True),
|
||||
("other", 1, False),
|
||||
],
|
||||
)
|
||||
async def test_m1_output_probe_matches_grader_and_is_condition_scoped(
|
||||
tmp_path, condition, return_code, raises
|
||||
):
|
||||
manifest = write_manifest(tmp_path).model_copy(update={"condition": condition})
|
||||
environment = Environment(
|
||||
responses={
|
||||
"hello.txt": ExecResult(stdout="", stderr="", return_code=return_code)
|
||||
}
|
||||
)
|
||||
if raises:
|
||||
with pytest.raises(RuntimeLaunchError, match="/app/hello.txt"):
|
||||
await runtime(tmp_path)._verify_m1_output(environment, manifest)
|
||||
else:
|
||||
await runtime(tmp_path)._verify_m1_output(environment, manifest)
|
||||
probed = [cmd for cmd, _ in environment.commands if "hello.txt" in cmd]
|
||||
assert bool(probed) == (condition == "M1-hello-world")
|
||||
|
||||
|
||||
async def test_wait_for_done_requires_orchestrator_authorship(tmp_path, monkeypatch):
|
||||
rt = runtime(tmp_path, poll_seconds=0)
|
||||
orch = credential("orch-1", "orchestrator", "orch-model")
|
||||
trial = trial_handle((orch,))
|
||||
rounds = iter(
|
||||
[
|
||||
[{"id": "1", "pubkey": "someone-else", "content": "DONE: fake"}],
|
||||
[{"id": "2", "pubkey": orch.nostr_pubkey, "content": "DONE: real"}],
|
||||
]
|
||||
)
|
||||
observers = []
|
||||
|
||||
async def buzz_json(credential, *args, **kwargs):
|
||||
observers.append(credential.agent_id)
|
||||
return next(rounds)
|
||||
|
||||
monkeypatch.setattr(rt, "_buzz_json", buzz_json)
|
||||
result = await rt._wait_for_done(Environment(), orch, trial, [])
|
||||
assert json.dumps(result).find("real") > 0
|
||||
# observation happens as the trial user, never as an agent identity
|
||||
assert set(observers) == {"user"}
|
||||
|
||||
|
||||
def test_composed_system_prompt_carries_persona_and_team_roster(tmp_path):
|
||||
rt = runtime(tmp_path)
|
||||
orch = credential("orch-1", "orchestrator", "orch-model")
|
||||
worker_1 = credential("worker-1", "worker", "worker-model")
|
||||
worker_2 = credential("worker-2", "worker", "worker-model")
|
||||
trial = trial_handle((orch, worker_1, worker_2))
|
||||
persona = tmp_path / "persona.md"
|
||||
persona.write_text("# Persona body\n", encoding="utf-8")
|
||||
|
||||
path = rt._compose_system_prompt(
|
||||
trial_dir=tmp_path,
|
||||
trial=trial,
|
||||
credential=orch,
|
||||
persona_path=persona,
|
||||
)
|
||||
|
||||
composed = path.read_text(encoding="utf-8")
|
||||
assert composed.startswith("# Persona body\n")
|
||||
assert "You are `orch-1` (pubkey `pubkey-orch-1`)" in composed
|
||||
assert f"channel `{trial.channel_id}`" in composed
|
||||
assert "user `user` (pubkey `pubkey-user`)" in composed
|
||||
# roster lists teammates, never the agent itself
|
||||
assert "| worker-1 | worker | `pubkey-worker-1` |" in composed
|
||||
assert "| worker-2 | worker | `pubkey-worker-2` |" in composed
|
||||
assert "| orch-1 " not in composed
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
async def test_stop_agents_sweeps_the_uploaded_stack(tmp_path):
|
||||
from harbor_buzz_orchestra.container_runtime import _Agent
|
||||
|
||||
environment = Environment()
|
||||
agents = [_Agent(credential("orch-1", "orchestrator", "orch-model"), 1, "o", "e")]
|
||||
await BuzzContainerRuntime._stop_agents(environment, agents)
|
||||
sweeps = [cmd for cmd, _ in environment.commands if REMOTE_BIN in cmd]
|
||||
assert len(sweeps) == 2
|
||||
assert "kill -TERM" in sweeps[0] and "kill -KILL" in sweeps[1]
|
||||
@@ -1,47 +0,0 @@
|
||||
import copy
|
||||
import pytest
|
||||
import yaml
|
||||
from harbor_buzz_orchestra import ExperimentManifest, ManifestError
|
||||
|
||||
|
||||
def test_hash_is_independent_of_mapping_and_yaml_key_order(tmp_path, manifest_data):
|
||||
first = ExperimentManifest.load(manifest_data)
|
||||
path = tmp_path / "manifest.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
dict(reversed(list(copy.deepcopy(manifest_data).items()))), sort_keys=False
|
||||
)
|
||||
)
|
||||
second = ExperimentManifest.load(path)
|
||||
assert first.canonical_bytes() == second.canonical_bytes()
|
||||
assert first.sha256 == second.sha256
|
||||
assert len(first.sha256) == 64
|
||||
|
||||
|
||||
def test_hash_changes_when_staffing_changes(manifest_data):
|
||||
first = ExperimentManifest.load(manifest_data)
|
||||
changed = copy.deepcopy(manifest_data)
|
||||
changed["roster"][1]["count"] = 3
|
||||
assert ExperimentManifest.load(changed).sha256 != first.sha256
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "match"),
|
||||
[
|
||||
(lambda data: data.update({"unknown": True}), "Extra inputs"),
|
||||
(lambda data: data["roster"].pop(0), "exactly one orchestrator"),
|
||||
(lambda data: data["prices"].pop("databricks/qwen"), "prices missing"),
|
||||
(lambda data: data["roster"][1].update({"concurrency": 5}), "concurrency"),
|
||||
],
|
||||
)
|
||||
def test_invalid_manifest_is_rejected(manifest_data, mutation, match):
|
||||
mutation(manifest_data)
|
||||
with pytest.raises(ManifestError, match=match):
|
||||
ExperimentManifest.load(manifest_data)
|
||||
|
||||
|
||||
def test_non_mapping_document_is_rejected(tmp_path):
|
||||
path = tmp_path / "manifest.yaml"
|
||||
path.write_text("- not\n- a\n- mapping\n")
|
||||
with pytest.raises(ManifestError, match="root must be a mapping"):
|
||||
ExperimentManifest.load(path)
|
||||
@@ -1,125 +0,0 @@
|
||||
"""The leaderboard runner must emit only leaderboard-legal harbor settings."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
_SCRIPT = Path(__file__).parent.parent / "scripts" / "run_leaderboard.py"
|
||||
_spec = importlib.util.spec_from_file_location("run_leaderboard", _SCRIPT)
|
||||
run_leaderboard = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["run_leaderboard"] = run_leaderboard
|
||||
_spec.loader.exec_module(run_leaderboard)
|
||||
|
||||
FORBIDDEN_FLAGS = (
|
||||
"--timeout-multiplier",
|
||||
"--agent-timeout-multiplier",
|
||||
"--verifier-timeout-multiplier",
|
||||
"--agent-setup-timeout-multiplier",
|
||||
"--environment-build-timeout-multiplier",
|
||||
"--override-cpus",
|
||||
"--override-memory",
|
||||
"--override-storage",
|
||||
"--override-gpus",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def binaries(tmp_path):
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
found = {}
|
||||
for name in run_leaderboard.BINARIES:
|
||||
path = bin_dir / name
|
||||
path.write_text("#!/bin/sh\n")
|
||||
found[name] = path
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_binaries(tmp_path):
|
||||
bin_dir = tmp_path / "linux-bin"
|
||||
bin_dir.mkdir()
|
||||
for name in run_leaderboard.AGENT_BINARIES:
|
||||
(bin_dir / name).write_text("ELF")
|
||||
return run_leaderboard.find_agent_binaries(bin_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def args(tmp_path, binaries, agent_binaries):
|
||||
manifest = tmp_path / "team.yaml"
|
||||
manifest.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"condition": "unit-test",
|
||||
"roster": [
|
||||
{"id": "orch", "endpoint": "frontier"},
|
||||
{"id": "worker", "endpoint": "fast", "count": 2},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
endpoints = tmp_path / "endpoints.json"
|
||||
endpoints.write_text(json.dumps({"frontier": {"provider": "anthropic"}}))
|
||||
provisioner = tmp_path / "provisioner.json"
|
||||
provisioner.write_text("{}")
|
||||
return run_leaderboard.parse_args(
|
||||
[
|
||||
"--dataset",
|
||||
"terminal-bench/terminal-bench-2-1",
|
||||
"--attempts",
|
||||
"5",
|
||||
"--manifest",
|
||||
str(manifest),
|
||||
"--endpoint-config",
|
||||
str(endpoints),
|
||||
"--provisioner-config",
|
||||
str(provisioner),
|
||||
"--agent-bin-dir",
|
||||
str(next(iter(agent_binaries.values())).parent),
|
||||
"--job-name",
|
||||
"unit-test-job",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_command_uses_standard_settings_only(args, binaries, agent_binaries):
|
||||
command = run_leaderboard.build_command(args, binaries, agent_binaries)
|
||||
assert command[:2] == ["harbor", "run"]
|
||||
assert command.count("-k") == 1
|
||||
assert command[command.index("-k") + 1] == "5"
|
||||
for flag in FORBIDDEN_FLAGS:
|
||||
assert flag not in command
|
||||
# The full production stack rides in as agent kwargs.
|
||||
kwargs = [command[i + 1] for i, p in enumerate(command) if p == "--agent-kwarg"]
|
||||
assert any(k.startswith("buzz_acp_binary=") for k in kwargs)
|
||||
assert any(k.startswith("buzz_agent_binary=") for k in kwargs)
|
||||
assert any(k.startswith("buzz_dev_mcp_binary=") for k in kwargs)
|
||||
|
||||
|
||||
def test_agent_binaries_must_exist(tmp_path):
|
||||
with pytest.raises(SystemExit, match="buzz-dev-mcp"):
|
||||
run_leaderboard.find_agent_binaries(tmp_path)
|
||||
|
||||
|
||||
def test_forbidden_flags_are_not_accepted(tmp_path):
|
||||
for flag in FORBIDDEN_FLAGS:
|
||||
with pytest.raises(SystemExit):
|
||||
run_leaderboard.parse_args(
|
||||
["--dataset", "d", "--attempts", "5",
|
||||
"--agent-bin-dir", str(tmp_path), flag, "1"]
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_template_matches_harbor_schema(args, tmp_path):
|
||||
from harbor.leaderboard.metadata import load_metadata
|
||||
|
||||
path = run_leaderboard.write_metadata_template(args, tmp_path)
|
||||
loaded = load_metadata(path)
|
||||
assert loaded["agent_org_display_name"] == "Block"
|
||||
assert [m["model_name"] for m in loaded["models"]] == ["frontier", "fast"]
|
||||
assert loaded["models"][0]["model_org_display_name"] == "Anthropic"
|
||||
assert loaded["models"][1]["model_provider"] == "FILL_ME"
|
||||
2496
benchmarks/harbor-buzz-orchestra/uv.lock
generated
2496
benchmarks/harbor-buzz-orchestra/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1 +0,0 @@
|
||||
hermit
|
||||
@@ -1,7 +0,0 @@
|
||||
# Hermit environment
|
||||
|
||||
This is a [Hermit](https://github.com/cashapp/hermit) bin directory.
|
||||
|
||||
The symlinks in this directory are managed by Hermit and will automatically
|
||||
download and install Hermit itself as well as packages. These packages are
|
||||
local to this environment.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user