feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
[profile.dev]
|
||||||
|
# Full file/line backtraces, no per-variable debug info. ~20-30% faster
|
||||||
|
# builds and much faster macOS link. Need lldb locals? Override per-shell:
|
||||||
|
# CARGO_PROFILE_DEV_DEBUG=2
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
# CMake 4 (pinned by hermit) removed compat with projects declaring
|
||||||
|
# cmake_minimum_required < 3.5; audiopus_sys's vendored opus declares 3.1.
|
||||||
|
# Same workaround CI uses. A value already set in the environment wins.
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Build outputs
|
||||||
|
target/
|
||||||
|
**/target/
|
||||||
|
desktop/src-tauri/target/
|
||||||
|
|
||||||
|
# Native non-relay UI builds (not part of the public image)
|
||||||
|
desktop/
|
||||||
|
mobile/
|
||||||
|
|
||||||
|
# Node artifacts (built inside the image; host outputs would invalidate cache)
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# VCS, IDE, scratch
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.scratch/
|
||||||
|
|
||||||
|
# Local environment / secrets — never want these in the build context
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# OS junk
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# CI artifacts and tooling that the image doesn't need
|
||||||
|
.cache/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
bin/
|
||||||
|
sidecars/
|
||||||
|
|
||||||
|
# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is
|
||||||
|
# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files
|
||||||
|
# inside crates (Cargo.toml `readme = ...`) likewise need to be present to
|
||||||
|
# avoid manifest warnings; the savings from excluding them are negligible.
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Old Dockerfile noise (this Dockerfile is THE source of truth now)
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
prometheus.yml
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Buzz Backend — Local Development Environment
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and adjust as needed:
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# All defaults here work with `docker compose up` out of the box.
|
||||||
|
#
|
||||||
|
# Service ports (defaults):
|
||||||
|
# Postgres → localhost:5432
|
||||||
|
# Redis → localhost:6379
|
||||||
|
# Typesense → localhost:8108
|
||||||
|
# Adminer → localhost:8082 (DB browser UI)
|
||||||
|
#
|
||||||
|
# Note: If port 8082 conflicts, change the adminer port in docker-compose.yml
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database (Postgres 17)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz
|
||||||
|
# Optional read-replica URL; unset/blank keeps all reads on the writer.
|
||||||
|
# READ_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5433/buzz
|
||||||
|
PGHOST=localhost
|
||||||
|
PGPORT=5432
|
||||||
|
PGUSER=buzz
|
||||||
|
PGPASSWORD=buzz_dev
|
||||||
|
PGDATABASE=buzz
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Redis 7
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
# Max connections in the relay's shared Redis pool (default 16).
|
||||||
|
# BUZZ_REDIS_POOL_SIZE=16
|
||||||
|
|
||||||
|
# Max connections in each of the relay's Postgres pools — writer and, when
|
||||||
|
# READ_DATABASE_URL is set, reader (default 50).
|
||||||
|
# BUZZ_DB_POOL_SIZE=50
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Typesense (search)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
TYPESENSE_API_KEY=buzz_dev_key
|
||||||
|
TYPESENSE_URL=http://localhost:8108
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Relay (WebSocket server)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Bind address for the relay (host:port)
|
||||||
|
BUZZ_BIND_ADDR=0.0.0.0:3000
|
||||||
|
# Public WebSocket URL — used in NIP-42 auth challenges
|
||||||
|
RELAY_URL=ws://localhost:3000
|
||||||
|
# Stable relay signing key. Set this in dev if you want REST-created forum posts
|
||||||
|
# to keep resolving to the original author across relay restarts.
|
||||||
|
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
|
||||||
|
# Optional: path to the web UI dist directory. When set, the relay serves
|
||||||
|
# the web frontend at / for browser requests. Leave unset for local dev
|
||||||
|
# (use `just web` for Vite HMR instead).
|
||||||
|
# BUZZ_WEB_DIR=./web/dist
|
||||||
|
|
||||||
|
# Shared Redis-backed admission limits. Defaults shown below; each value must
|
||||||
|
# be a positive integer.
|
||||||
|
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
|
||||||
|
# BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
|
||||||
|
# BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
|
||||||
|
# BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120
|
||||||
|
# BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN=600
|
||||||
|
# BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300
|
||||||
|
# BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Git (NIP-34 bare repositories)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Root directory for ephemeral Git workspaces and the disposable pack cache.
|
||||||
|
# Default: ./repos (relative to CWD).
|
||||||
|
# BUZZ_GIT_REPO_PATH=./repos
|
||||||
|
# BUZZ_GIT_MAX_PACK_BYTES=524288000
|
||||||
|
# BUZZ_GIT_MAX_REPO_BYTES=1048576000
|
||||||
|
# Process-local immutable pack/index cache. Zero disables retention.
|
||||||
|
# BUZZ_GIT_PACK_CACHE_PATH=./repos/.pack-cache
|
||||||
|
# BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120
|
||||||
|
# BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# S3-Compatible Object Storage (media + Git/CAS)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The local MinIO container is reachable from host processes at localhost:9000.
|
||||||
|
# Path style keeps the bucket in the URL path and is required by this local DNS
|
||||||
|
# setup. Use `virtual` only when the provider requires bucket-as-subdomain URLs.
|
||||||
|
BUZZ_S3_ENDPOINT=http://localhost:9000
|
||||||
|
BUZZ_S3_ACCESS_KEY=buzz_dev
|
||||||
|
BUZZ_S3_SECRET_KEY=buzz_dev_secret
|
||||||
|
BUZZ_S3_BUCKET=buzz-media
|
||||||
|
BUZZ_S3_REGION=us-east-1
|
||||||
|
BUZZ_S3_ADDRESSING_STYLE=path
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Media Upload Admission
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Bound image/video parser and storage work admitted by one relay process.
|
||||||
|
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
|
||||||
|
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
|
||||||
|
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
|
||||||
|
# GET/HEAD /media/* always require Blossom t=get auth and relay membership.
|
||||||
|
# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer
|
||||||
|
# read; setting either (including to false) changes nothing and the relay warns
|
||||||
|
# about it at startup.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Ephemeral Channels (TTL testing)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Override the TTL for all ephemeral channels (in seconds). When set, any
|
||||||
|
# channel created with a TTL tag will use this value instead of the
|
||||||
|
# client-provided one. Unset to use the client-provided TTL.
|
||||||
|
# BUZZ_EPHEMERAL_TTL_OVERRIDE=60
|
||||||
|
|
||||||
|
# How often the reaper checks for expired ephemeral channels (default: 60s).
|
||||||
|
# BUZZ_REAPER_INTERVAL_SECS=5
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging / Tracing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug
|
||||||
|
# Optional OpenTelemetry-only target filter. This is deliberately independent
|
||||||
|
# from RUST_LOG so log verbosity changes cannot break trace parentage.
|
||||||
|
# BUZZ_OTEL_FILTER=buzz_relay=info,buzz_datastore=info
|
||||||
|
|
||||||
|
# OTLP tracing endpoint (optional — leave unset to disable)
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ACP (Agent Communication Protocol — buzz-acp harness)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The ACP harness bridges Buzz events to AI agents. Each env var below maps
|
||||||
|
# to a CLI flag of the same name (lowercase, hyphens → underscores). All values
|
||||||
|
# are optional unless noted; defaults are shown in comments.
|
||||||
|
#
|
||||||
|
# Quick start:
|
||||||
|
# BUZZ_PRIVATE_KEY=<hex> BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp
|
||||||
|
|
||||||
|
# ── Identity & auth ──────────────────────────────────────────────────────────
|
||||||
|
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
|
||||||
|
# BUZZ_PRIVATE_KEY=<32-byte hex or nsec1… private key>
|
||||||
|
|
||||||
|
# Relay WebSocket URL the harness connects to.
|
||||||
|
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
|
||||||
|
# connection target — they happen to point at the same place in local dev.
|
||||||
|
# BUZZ_RELAY_URL=ws://localhost:3000
|
||||||
|
|
||||||
|
# ── Agent subprocess ─────────────────────────────────────────────────────────
|
||||||
|
# Binary to spawn as the AI agent (e.g. "goose", "codex-acp", "claude-code").
|
||||||
|
# BUZZ_ACP_AGENT_COMMAND=goose
|
||||||
|
|
||||||
|
# Comma-separated arguments passed to the agent binary.
|
||||||
|
# Goose default: "acp". Codex/Claude default: "" (empty).
|
||||||
|
# BUZZ_ACP_AGENT_ARGS=acp
|
||||||
|
|
||||||
|
# Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent).
|
||||||
|
# BUZZ_ACP_MCP_COMMAND=
|
||||||
|
|
||||||
|
# Number of parallel agent subprocesses (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=
|
||||||
|
|
||||||
|
# ── Desktop development ──────────────────────────────────────────────────────
|
||||||
|
# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
|
||||||
|
# app launch while keeping the current identity and relay data.
|
||||||
|
# VITE_BUZZ_FORCE_FRESH_ONBOARDING=true
|
||||||
|
|
||||||
|
# ── Subscription & filtering ─────────────────────────────────────────────────
|
||||||
|
# Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
|
||||||
|
# BUZZ_ACP_SUBSCRIBE=mentions
|
||||||
|
|
||||||
|
# Comma-separated event kind numbers to subscribe to (overrides mode defaults).
|
||||||
|
# BUZZ_ACP_KINDS=
|
||||||
|
|
||||||
|
# Comma-separated channel UUIDs to limit subscription scope.
|
||||||
|
# BUZZ_ACP_CHANNELS=
|
||||||
|
|
||||||
|
# Set to true to disable the @-mention filter in mentions mode.
|
||||||
|
# BUZZ_ACP_NO_MENTION_FILTER=false
|
||||||
|
|
||||||
|
# Path to TOML config file for rule-based subscriptions (config mode).
|
||||||
|
# BUZZ_ACP_CONFIG=./buzz-acp.toml
|
||||||
|
|
||||||
|
# ── Dedup & self-ignore ──────────────────────────────────────────────────────
|
||||||
|
# How to handle duplicate events: "queue" (default) or "drop".
|
||||||
|
# BUZZ_ACP_DEDUP=queue
|
||||||
|
|
||||||
|
# Set to true to process the agent's own messages (default: ignore self).
|
||||||
|
# BUZZ_ACP_NO_IGNORE_SELF=false
|
||||||
|
|
||||||
|
# ── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
# Max context messages fetched for thread replies and DMs (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
|
||||||
|
|
||||||
|
# Optional relay join policy. Markdown is served by the relay so every join
|
||||||
|
# surface can present the same documents. Each document and the independent age
|
||||||
|
# attestation are optional; configuring any one enables policy acceptance.
|
||||||
|
# BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here."
|
||||||
|
# BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here."
|
||||||
|
# BUZZ_AGE_ATTESTATION_REQUIRED=true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Git for Windows defaults to core.autocrlf=true, so without this every text
|
||||||
|
# file lands in the working copy with CRLF. Biome formats with LF
|
||||||
|
# (biome.json sets no lineEnding override), which fails `biome check` on
|
||||||
|
# effectively every file, and
|
||||||
|
# desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs asserts on
|
||||||
|
# patches/*.patch with `\n`-joined patterns. Normalize to LF in the working
|
||||||
|
# copy on every platform; the stored blobs are already LF.
|
||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @block/buzz-oss-team
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report a reproducible bug in Buzz
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**Steps to reproduce**
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
What did you expect to happen?
|
||||||
|
|
||||||
|
**Version and platform**
|
||||||
|
Find your version at the bottom of the Settings sidebar. Write "unknown" if you can't determine it.
|
||||||
|
- Buzz version:
|
||||||
|
- OS:
|
||||||
|
|
||||||
|
**Logs / additional context**
|
||||||
|
Paste any relevant logs, error messages, or screenshots here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Propose a new feature or improvement
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
**Motivation**
|
||||||
|
What problem does this solve? Who runs into it and when?
|
||||||
|
|
||||||
|
**Proposed solution**
|
||||||
|
Describe what you'd like to see.
|
||||||
|
|
||||||
|
**Alternatives considered**
|
||||||
|
What other approaches did you consider?
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Anything else that helps — links, screenshots, prior art.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Before opening: please [search open issues and PRs](https://github.com/block/buzz/issues?q=is%3Aopen) for duplicates — link the closest one, or say "none found".
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
## Summary
|
||||||
|
<!-- What does this change and why? -->
|
||||||
|
|
||||||
|
### Related issue
|
||||||
|
<!-- Fixes #1234, or N/A. Before opening: search existing issues/PRs for duplicates — link the closest one, or say "none found". -->
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
<!-- How was this verified? UI change? Include before/after screenshots (or a short recording). -->
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
name: Auto-tag on Release PR Merge
|
||||||
|
|
||||||
|
# Four PR-driven release lanes share this workflow. Each uses an explicit branch
|
||||||
|
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
|
||||||
|
# a chart feature PR can publish its own new version when merged:
|
||||||
|
#
|
||||||
|
# version-bump/<v> → tag desktop-v<v> → release.yml (desktop app)
|
||||||
|
# relay-release/<v> → tag relay-v<v> → docker.yml (relay image)
|
||||||
|
# chart-release/<v> → tag chart-v<v> → helm-chart.yml (main helm chart)
|
||||||
|
# push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml
|
||||||
|
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
|
||||||
|
# → tag chart-v<v> → helm-chart.yml (helm chart)
|
||||||
|
#
|
||||||
|
# Mobile candidate tags do not come from merged PRs. Operators create immutable
|
||||||
|
# mobile-v<v>-rc.N tags directly from remote main with scripts/mobile-release.sh,
|
||||||
|
# then hand the exact tag to buzz-releases.
|
||||||
|
#
|
||||||
|
# Release tags are created with a short-lived token from the dedicated
|
||||||
|
# buzz-release-bot GitHub App. GitHub attributes the ref creation to that
|
||||||
|
# App, so the consumer's `on.push.tags` trigger runs normally. The workflow's
|
||||||
|
# default GITHUB_TOKEN remains read-only and is never used to create a tag.
|
||||||
|
#
|
||||||
|
# Mobile is manual-only by infosec necessity: OSS `block/buzz` CI must
|
||||||
|
# not trigger CI in the private `buzz-releases` repo. A human feeds the exact
|
||||||
|
# mobile candidate tag to the private Buildkite pipeline, which builds and
|
||||||
|
# ships mobile.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-tag:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
checks: read
|
||||||
|
statuses: read
|
||||||
|
if: >
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Resolve release lane and version
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||||
|
run: |
|
||||||
|
# Explicit release branches retain their established behavior. For an
|
||||||
|
# ordinary internal PR, publish only when Chart.yaml itself changed
|
||||||
|
# and its version differs from the PR's base commit.
|
||||||
|
case "$BRANCH" in
|
||||||
|
version-bump/*)
|
||||||
|
VERSION="${BRANCH#version-bump/}"
|
||||||
|
TAG_PREFIX="desktop-v" ;;
|
||||||
|
relay-release/*)
|
||||||
|
VERSION="${BRANCH#relay-release/}"
|
||||||
|
TAG_PREFIX="relay-v" ;;
|
||||||
|
chart-release/*)
|
||||||
|
VERSION="${BRANCH#chart-release/}"
|
||||||
|
TAG_PREFIX="chart-v" ;;
|
||||||
|
push-chart-release/*)
|
||||||
|
VERSION="${BRANCH#push-chart-release/}"
|
||||||
|
TAG_PREFIX="push-chart-v" ;;
|
||||||
|
*)
|
||||||
|
parent_sha="$(git rev-parse HEAD^)"
|
||||||
|
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
|
||||||
|
VERSION="$(awk '/^version:/ {print $2}' deploy/charts/buzz/Chart.yaml)"
|
||||||
|
if [ -z "$old_version" ] || [ -z "$VERSION" ] || [ "$old_version" = "$VERSION" ]; then
|
||||||
|
echo "No release branch or chart version bump — nothing to tag"
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TAG_PREFIX="chart-v" ;;
|
||||||
|
esac
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "::error::Invalid release version: '$VERSION'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "enabled=true"
|
||||||
|
echo "tag=${TAG_PREFIX}${VERSION}"
|
||||||
|
if [[ "$TAG_PREFIX" == desktop-v ]]; then
|
||||||
|
echo "target_sha=${{ github.event.pull_request.head.sha }}"
|
||||||
|
echo "desktop=true"
|
||||||
|
else
|
||||||
|
echo "target_sha=$GITHUB_SHA"
|
||||||
|
echo "desktop=false"
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Tagging ${TAG_PREFIX}${VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
- name: Verify immutable reviewed desktop candidate
|
||||||
|
if: steps.release.outputs.desktop == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ steps.release.outputs.tag }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||||
|
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||||
|
MERGED_AT: ${{ github.event.pull_request.merged_at }}
|
||||||
|
run: |
|
||||||
|
VERSION="${VERSION#desktop-v}"
|
||||||
|
export VERSION
|
||||||
|
scripts/verify-desktop-release-merge.sh
|
||||||
|
|
||||||
|
- name: Create release tagger token
|
||||||
|
if: steps.release.outputs.enabled == 'true'
|
||||||
|
id: release-tagger
|
||||||
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
|
with:
|
||||||
|
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
|
||||||
|
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
|
||||||
|
permission-contents: write
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
if: steps.release.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
|
||||||
|
TAG: ${{ steps.release.outputs.tag }}
|
||||||
|
TARGET_SHA: ${{ steps.release.outputs.target_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Check gh's exit status, not its output. A missing ref returns a 404
|
||||||
|
# JSON body on stdout, which must not be mistaken for an existing tag.
|
||||||
|
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then
|
||||||
|
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
|
||||||
|
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
|
||||||
|
echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||||
|
-f ref="refs/tags/$TAG" \
|
||||||
|
-f sha="$TARGET_SHA" \
|
||||||
|
--silent; then
|
||||||
|
# Ref creation is atomic. A concurrent retry may have won the race;
|
||||||
|
# accept that only when it created the exact immutable ref.
|
||||||
|
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
|
||||||
|
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
|
||||||
|
echo "Tag $TAG was concurrently created at $TARGET_SHA"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Harbor Buzz Orchestra
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "benchmarks/harbor-buzz-orchestra/**"
|
||||||
|
- ".github/workflows/benchmark-harbor.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "benchmarks/harbor-buzz-orchestra/**"
|
||||||
|
- ".github/workflows/benchmark-harbor.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Python tests and lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
- name: Test adapter
|
||||||
|
working-directory: benchmarks/harbor-buzz-orchestra
|
||||||
|
run: |
|
||||||
|
python -m pip install --disable-pip-version-check -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
ruff check .
|
||||||
|
- name: Test provisioner
|
||||||
|
working-directory: benchmarks/harbor-buzz-orchestra/testbed
|
||||||
|
run: |
|
||||||
|
python -m pip install --disable-pip-version-check -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
ruff check .
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
name: Desktop release cache tag-scope proof
|
||||||
|
|
||||||
|
# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all
|
||||||
|
# four canaries. Every job restores only and requires an exact cache hit.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
name: Prove macOS ${{ matrix.target }} cache visibility
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
features: mesh-llm
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
features: default
|
||||||
|
steps:
|
||||||
|
- name: Require cache proof tag
|
||||||
|
run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
- name: Patch proof dependency graph
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
env:
|
||||||
|
CACHE_TARGET: ${{ matrix.target }}
|
||||||
|
CACHE_FEATURES: ${{ matrix.features }}
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore exact default-branch cache from tag
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
- name: Require exact cache hit
|
||||||
|
env:
|
||||||
|
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
|
||||||
|
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
|
||||||
|
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
|
||||||
|
|
||||||
|
linux:
|
||||||
|
name: Prove Linux cache visibility
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
|
||||||
|
timeout-minutes: 15
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Require cache proof tag and install release native tools
|
||||||
|
run: |
|
||||||
|
[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
- name: Patch proof dependency graph
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore exact default-branch cache from tag
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
- name: Require exact cache hit
|
||||||
|
env:
|
||||||
|
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
|
||||||
|
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
|
||||||
|
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Prove Windows cache visibility
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Require cache proof tag
|
||||||
|
shell: bash
|
||||||
|
run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }'
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Patch proof dependency graph
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
shell: bash
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore exact default-branch cache from tag
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
- name: Require exact cache hit
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }}
|
||||||
|
CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }}
|
||||||
|
EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Desktop Release Candidate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Desktop Release Candidate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Validate immutable desktop candidate
|
||||||
|
if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ github.event.pull_request.head.ref }}
|
||||||
|
run: |
|
||||||
|
VERSION="${VERSION#version-bump/}"
|
||||||
|
scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY"
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
name: Docker image
|
||||||
|
|
||||||
|
# Builds and publishes the public Buzz relay images as ghcr.io/block/buzz.
|
||||||
|
# Normal tags contain stripped binaries; matching debug-* tags contain the same
|
||||||
|
# optimized binaries with line-table debug information for native profilers.
|
||||||
|
#
|
||||||
|
# Strategy: each architecture builds on its native runner (ubuntu-24.04 for
|
||||||
|
# amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final
|
||||||
|
# job stitches the per-arch digests into a single multi-arch manifest.
|
||||||
|
# This avoids QEMU emulation (~10× slower for Rust) at zero cost on free
|
||||||
|
# GitHub-hosted runners.
|
||||||
|
#
|
||||||
|
# Versioning: the relay is versioned independently of the desktop app via
|
||||||
|
# its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and
|
||||||
|
# agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so
|
||||||
|
# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push to main → :main + :sha-<7>
|
||||||
|
# + :debug-main + :debug-sha-<7>
|
||||||
|
# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major}
|
||||||
|
# + matching :debug-* tags
|
||||||
|
# (+ :latest/:debug-latest for stable releases)
|
||||||
|
# - pull_request → build only (no push), cache stays warm
|
||||||
|
# - workflow_dispatch → manual relay-tag rescue at the tag itself
|
||||||
|
#
|
||||||
|
# Why workflow_dispatch carries a version input:
|
||||||
|
# Normal releases arrive through the push:tags trigger above. The input is
|
||||||
|
# retained only for an operator to rerun publication manually at an immutable
|
||||||
|
# relay tag. The workflow rejects a dispatch whose github.ref, checked-out
|
||||||
|
# HEAD, and relay-v tag do not resolve to one commit.
|
||||||
|
# On the rescue path inputs.version is already bare (e.g. 0.3.0), so the
|
||||||
|
# match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value
|
||||||
|
# intact) and the bare version flows straight to the semver parser. On a
|
||||||
|
# real push event value= is empty and the match strips relay-v from the ref.
|
||||||
|
#
|
||||||
|
# The :latest tag tracks the latest STABLE relay release: metadata-action's
|
||||||
|
# `flavor.latest=auto` (its default) emits :latest only for non-prerelease
|
||||||
|
# semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest,
|
||||||
|
# and main pushes (no semver tag) never produce :latest.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["relay-v[0-9]*"]
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Dockerfile"
|
||||||
|
- "Dockerfile.push-gateway"
|
||||||
|
- ".dockerignore"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "rust-toolchain.toml"
|
||||||
|
- "crates/**"
|
||||||
|
- "web/**"
|
||||||
|
- "package.json"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- "pnpm-workspace.yaml"
|
||||||
|
- "patches/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# One image build per ref; cancel superseded PR builds, but never cancel
|
||||||
|
# tag/main builds (publishing must not be aborted mid-flight).
|
||||||
|
concurrency:
|
||||||
|
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Single source of truth for the image name. Set GHCR_IMAGE as a repo
|
||||||
|
# variable to override (e.g., for forks that want to push to their own
|
||||||
|
# namespace without forking this file).
|
||||||
|
IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.platform }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # push to GHCR
|
||||||
|
id-token: write # OIDC for build provenance attestation
|
||||||
|
attestations: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}"
|
||||||
|
scripts/verify-release-ref.sh relay-v "$VERSION"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
with:
|
||||||
|
# Default parallelism of 4 OOMs the 7GB GitHub runner during Rust
|
||||||
|
# compiles (see moby/buildkit#3969). Vaultwarden hit this; we will
|
||||||
|
# too without the cap.
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[worker.oci]
|
||||||
|
max-parallelism = 2
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
# Skip on pull_request from forks — no GHCR creds, build-only.
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
# Tag matrix — every main commit gets sha-<7>, relay releases get the
|
||||||
|
# full semver family. The semver entries carry match=^relay-v(.*)$
|
||||||
|
# because metadata-action does NOT strip a `relay-v` prefix on its
|
||||||
|
# own — it only strips refs/tags/, then runs the raw ref through
|
||||||
|
# semver.valid(), which rejects "relay-v0.3.0". The match capture
|
||||||
|
# group feeds the bare version to the semver parser. value= supplies
|
||||||
|
# the version on a manual rescue dispatch (github.ref is `main`
|
||||||
|
# there, not the tag): it is already bare, so match no-ops (warns,
|
||||||
|
# value intact) and the bare version validates as-is. On push value=
|
||||||
|
# is empty, so the ref drives it and match strips relay-v — push
|
||||||
|
# behavior is unchanged. Pull requests get nothing (push: false
|
||||||
|
# below). :latest is intentionally absent — flavor.latest defaults to
|
||||||
|
# `auto`, which adds :latest for stable semver tags only (not
|
||||||
|
# prereleases, not main pushes).
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Buzz
|
||||||
|
org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform
|
||||||
|
org.opencontainers.image.licenses=Apache-2.0
|
||||||
|
|
||||||
|
- name: Build and push release image by digest
|
||||||
|
id: build-release
|
||||||
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
target: runtime
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# Push by digest, not by tag — the merge job assembles the tags
|
||||||
|
# into one multi-arch manifest. This is what makes the native-arm
|
||||||
|
# matrix possible.
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
|
||||||
|
cache-to: |
|
||||||
|
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
|
||||||
|
|
||||||
|
- name: Build and push debug image by digest
|
||||||
|
id: build-debug
|
||||||
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
target: runtime-debug
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Export release and debug digests
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
env:
|
||||||
|
RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }}
|
||||||
|
DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests-release /tmp/digests-debug
|
||||||
|
touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}"
|
||||||
|
touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload release digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: digests-release-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests-release/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload debug digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: digests-debug-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests-debug/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Merge ${{ matrix.variant }} multi-arch manifest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: build
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # push the merged manifest
|
||||||
|
id-token: write # OIDC for provenance attestation on the manifest
|
||||||
|
attestations: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- variant: release
|
||||||
|
tag_prefix: ""
|
||||||
|
- variant: debug
|
||||||
|
tag_prefix: debug-
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all per-arch digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-${{ matrix.variant }}-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
# Must mirror the build job's tag matrix exactly — the merge job
|
||||||
|
# re-derives tags to stamp them onto the multi-arch manifest. See
|
||||||
|
# the build job's `meta` step for why match=^relay-v(.*)$, why
|
||||||
|
# value=${{ inputs.version }} carries the rescue-dispatch version,
|
||||||
|
# and why :latest is left to flavor.latest=auto.
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
prefix=${{ matrix.tag_prefix }},onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=sha,prefix=${{ matrix.tag_prefix }}sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
|
||||||
|
- name: Create and push manifest list
|
||||||
|
id: manifest
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
|
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Build -t flags from the metadata-action output.
|
||||||
|
tags=()
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
[ -n "$tag" ] && tags+=("-t" "$tag")
|
||||||
|
done <<< "$META_TAGS"
|
||||||
|
|
||||||
|
# Build the digest refs from the per-arch artifacts.
|
||||||
|
digests=()
|
||||||
|
for digest in *; do
|
||||||
|
digests+=("${IMAGE_NAME}@sha256:${digest}")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
|
||||||
|
|
||||||
|
# Capture the merged manifest digest for the attestation step.
|
||||||
|
first_tag=$(echo "$META_TAGS" | head -n1)
|
||||||
|
merged_digest=$(docker buildx imagetools inspect "$first_tag" \
|
||||||
|
--format '{{json .Manifest}}' | jq -r '.digest')
|
||||||
|
echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Attest provenance for the merged image
|
||||||
|
# Sigstore-signed in-toto attestation, verifiable with:
|
||||||
|
# gh attestation verify oci://ghcr.io/block/buzz:<tag> --owner block
|
||||||
|
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||||
|
with:
|
||||||
|
subject-name: ${{ env.IMAGE_NAME }}
|
||||||
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
|
VARIANT: ${{ matrix.variant }}
|
||||||
|
MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||||
|
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "### Published \`${IMAGE_NAME}\` (${VARIANT})"
|
||||||
|
echo
|
||||||
|
echo "**Digest:** \`${MERGED_DIGEST}\`"
|
||||||
|
echo
|
||||||
|
echo "**Tags:**"
|
||||||
|
echo '```'
|
||||||
|
echo "${META_TAGS}"
|
||||||
|
echo '```'
|
||||||
|
echo
|
||||||
|
echo "Verify provenance:"
|
||||||
|
echo '```'
|
||||||
|
echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block"
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
push-gateway-build:
|
||||||
|
name: Build public push gateway (${{ matrix.platform }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}"
|
||||||
|
scripts/verify-release-ref.sh relay-v "$VERSION"
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[worker.oci]
|
||||||
|
max-parallelism = 2
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ghcr.io/block/buzz-push-gateway
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Buzz Push Gateway
|
||||||
|
org.opencontainers.image.description=Capability-gated APNs last hop for Buzz
|
||||||
|
org.opencontainers.image.licenses=Apache-2.0
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.push-gateway
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||||
|
cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }}
|
||||||
|
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }}
|
||||||
|
- name: Export digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
env:
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}"
|
||||||
|
- name: Upload digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: gateway-digests-${{ matrix.arch }}
|
||||||
|
path: /tmp/gateway-digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
push-gateway-merge:
|
||||||
|
name: Publish public push gateway image
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: push-gateway-build
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
steps:
|
||||||
|
- name: Download per-arch digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: /tmp/gateway-digests
|
||||||
|
pattern: gateway-digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ghcr.io/block/buzz-push-gateway
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }}
|
||||||
|
type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }}
|
||||||
|
- name: Merge and publish manifest
|
||||||
|
id: manifest
|
||||||
|
working-directory: /tmp/gateway-digests
|
||||||
|
env:
|
||||||
|
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS"
|
||||||
|
digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done
|
||||||
|
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
|
||||||
|
first_tag=$(echo "$META_TAGS" | head -n1)
|
||||||
|
digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest')
|
||||||
|
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Attest gateway image provenance
|
||||||
|
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/block/buzz-push-gateway
|
||||||
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
- name: Gateway publication summary
|
||||||
|
env:
|
||||||
|
GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||||
|
GATEWAY_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
{
|
||||||
|
echo "### Published \`ghcr.io/block/buzz-push-gateway\`"
|
||||||
|
echo
|
||||||
|
printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST"
|
||||||
|
echo
|
||||||
|
echo '**Tags:**'
|
||||||
|
echo "\`\`\`"
|
||||||
|
printf '%s\n' "$GATEWAY_TAGS"
|
||||||
|
echo "\`\`\`"
|
||||||
|
echo
|
||||||
|
echo 'Verify provenance before deployment:'
|
||||||
|
echo "\`\`\`"
|
||||||
|
printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST"
|
||||||
|
echo "\`\`\`"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
name: helm chart
|
||||||
|
|
||||||
|
# Lints + unit-tests + render-checks the chart on every PR/main push, and
|
||||||
|
# PUBLISHES it to GHCR as an OCI artifact on `chart-v*` tags.
|
||||||
|
#
|
||||||
|
# Publishing mirrors the relay image (see docker.yml): the chart is versioned
|
||||||
|
# independently of the desktop app and the relay via its own `chart-v*` tags
|
||||||
|
# (Chart.yaml `version`, cut by merging a `chart-release/<version>` PR). Only
|
||||||
|
# `chart-v*` tags publish — `main` pushes and PRs stay lint/render-only so we
|
||||||
|
# never overwrite a released chart version from an in-progress `main`.
|
||||||
|
#
|
||||||
|
# Why workflow_dispatch carries version/ref inputs:
|
||||||
|
# Normal releases arrive through the `push.tags` trigger below. The inputs are
|
||||||
|
# retained only for an operator to rerun publication manually at an immutable
|
||||||
|
# chart tag; the publish job checks out inputs.ref and packages at
|
||||||
|
# inputs.version.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Chart semver e.g. 0.1.0 (no chart-v prefix) — for chart-tag rescue dispatch"
|
||||||
|
required: false
|
||||||
|
ref:
|
||||||
|
description: "Chart tag ref to publish, e.g. chart-v0.1.0 (required when version is set)"
|
||||||
|
required: false
|
||||||
|
default: main
|
||||||
|
push:
|
||||||
|
# No `paths` filter here: GitHub applies a push `paths` filter to tag
|
||||||
|
# pushes too, so a `chart-v*` tag whose commit didn't touch a chart file
|
||||||
|
# would be filtered out and never publish. docker.yml / release.yml / sprig
|
||||||
|
# all keep `paths` out of a tag-carrying `push` for exactly this reason —
|
||||||
|
# PR runs stay scoped via `pull_request.paths` below; main pushes lint
|
||||||
|
# unconditionally (cheap), and tag pushes always run so publish can fire.
|
||||||
|
branches: [main]
|
||||||
|
tags: ["chart-v[0-9]*"]
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "deploy/charts/buzz/**"
|
||||||
|
- "deploy/charts/buzz-push-gateway/**"
|
||||||
|
- ".github/workflows/helm-chart.yml"
|
||||||
|
- "ct.yaml"
|
||||||
|
|
||||||
|
# Match docker.yml: deny-by-default, each job grants only what it needs.
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Single source of truth for the OCI chart repository (helm appends the chart
|
||||||
|
# name `buzz`, yielding oci://ghcr.io/block/buzz/charts/buzz, which is exactly
|
||||||
|
# the install ref documented in deploy/charts/buzz/README.md). Set
|
||||||
|
# GHCR_CHART_REPO as a repo variable to override (e.g., forks pushing to their
|
||||||
|
# own namespace without editing this file) — mirrors docker.yml's GHCR_IMAGE.
|
||||||
|
CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || 'oci://ghcr.io/block/buzz/charts' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-unittest:
|
||||||
|
name: lint + unittest + render matrix
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
# On chart-tag rescue dispatch, lint/render the tagged commit that the
|
||||||
|
# publish job will package, not whatever `main` is when the dispatch
|
||||||
|
# runs. Empty string = default ref for push/PR events.
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||||
|
with:
|
||||||
|
version: v3.16.4
|
||||||
|
|
||||||
|
- name: Install helm-unittest plugin
|
||||||
|
run: helm plugin install --version 0.8.2 https://github.com/helm-unittest/helm-unittest
|
||||||
|
|
||||||
|
- name: Set up Python (for chart-testing)
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up chart-testing
|
||||||
|
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||||
|
|
||||||
|
- name: Build chart dependencies
|
||||||
|
run: helm dependency build deploy/charts/buzz
|
||||||
|
|
||||||
|
- name: Push gateway chart lint + render guard
|
||||||
|
run: deploy/charts/buzz-push-gateway/tests/render.sh
|
||||||
|
|
||||||
|
- name: ct lint
|
||||||
|
run: ct lint --config ct.yaml --all
|
||||||
|
|
||||||
|
- name: helm-unittest
|
||||||
|
run: helm unittest deploy/charts/buzz
|
||||||
|
|
||||||
|
- name: helm template (render every fixture)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for f in deploy/charts/buzz/ci/*-values.yaml deploy/charts/buzz/tests/fixtures/*-values.yaml; do
|
||||||
|
echo "::group::render $f"
|
||||||
|
helm template buzz deploy/charts/buzz -f "$f"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
|
||||||
|
install-on-kind:
|
||||||
|
# Full end-to-end install requires the public ghcr.io/block/buzz image to
|
||||||
|
# exist and to embed Max's startup migrations. Runs only after Sami's
|
||||||
|
# image PR merges (`workflow_dispatch`) or on a schedule once main carries
|
||||||
|
# both prerequisites. Render/lint above is the per-PR signal.
|
||||||
|
#
|
||||||
|
# `inputs.version == ''` excludes the chart-tag rescue dispatch (which
|
||||||
|
# carries a version): that path only packages+publishes, it does not install.
|
||||||
|
name: install on kind (gated)
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.version == ''
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-unittest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||||
|
with:
|
||||||
|
version: v3.16.4
|
||||||
|
|
||||||
|
- name: Set up Python (for chart-testing)
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up chart-testing
|
||||||
|
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
|
||||||
|
|
||||||
|
- name: Create kind cluster
|
||||||
|
uses: helm/kind-action@0025e74a8c7512023d06dc019c617aa3cf561fde # v1.10.0
|
||||||
|
with:
|
||||||
|
version: v0.24.0
|
||||||
|
node_image: kindest/node:v1.31.0
|
||||||
|
|
||||||
|
- name: Build chart dependencies
|
||||||
|
run: helm dependency build deploy/charts/buzz
|
||||||
|
|
||||||
|
- name: ct install (quickstart profile)
|
||||||
|
run: ct install --config ct.yaml --charts deploy/charts/buzz --helm-extra-args "--timeout 600s"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
# Packages the chart and pushes it to GHCR as an OCI artifact. Fires only on
|
||||||
|
# a `chart-v*` tag push or a manual rescue dispatch (which
|
||||||
|
# carries inputs.version) — never on `main` pushes or PRs, so an in-progress
|
||||||
|
# `main` can never overwrite a released chart version. Mirrors docker.yml's
|
||||||
|
# GHCR publish (login with GITHUB_TOKEN, packages: write, fork override var).
|
||||||
|
name: publish chart to GHCR
|
||||||
|
if: >
|
||||||
|
startsWith(github.ref, 'refs/tags/chart-v') ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && inputs.version != '')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-unittest
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # push the chart to GHCR
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
# On the rescue dispatch, build the tagged commit (github.ref is
|
||||||
|
# `main` there); on a tag push, the default ref is already the tag.
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||||
|
with:
|
||||||
|
version: v3.16.4
|
||||||
|
|
||||||
|
- name: Resolve chart version
|
||||||
|
id: ver
|
||||||
|
env:
|
||||||
|
# Bare on the rescue dispatch; empty on a tag push (derive from ref).
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "$INPUT_VERSION" ]; then
|
||||||
|
version="$INPUT_VERSION"
|
||||||
|
tag_ref="refs/tags/chart-v${version}"
|
||||||
|
tag_sha="$(git rev-parse -q --verify "${tag_ref}^{commit}" || true)"
|
||||||
|
head_sha="$(git rev-parse HEAD)"
|
||||||
|
if [ -z "$tag_sha" ] || [ "$head_sha" != "$tag_sha" ]; then
|
||||||
|
echo "::error::workflow_dispatch with version '$version' must check out matching tag 'chart-v${version}' (HEAD=$head_sha, tag=${tag_sha:-missing})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# refs/tags/chart-v0.1.0 → github.ref_name is `chart-v0.1.0`.
|
||||||
|
version="${REF_NAME#chart-v}"
|
||||||
|
fi
|
||||||
|
if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "::error::Resolved chart version '$version' is not valid semver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The tag is the source of truth, but the published artifact's version
|
||||||
|
# comes from Chart.yaml — they must agree or we'd publish a mislabeled
|
||||||
|
# chart. Fail loudly on drift rather than silently shipping a mismatch.
|
||||||
|
chart_version="$(helm show chart deploy/charts/buzz | awk '/^version:/ {print $2}')"
|
||||||
|
if [ "$chart_version" != "$version" ]; then
|
||||||
|
echo "::error::Tag version '$version' != Chart.yaml version '$chart_version'. Bump Chart.yaml to match the tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Publishing chart version $version"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build chart dependencies
|
||||||
|
run: helm dependency build deploy/charts/buzz
|
||||||
|
|
||||||
|
- name: Package chart
|
||||||
|
run: helm package deploy/charts/buzz --destination dist
|
||||||
|
|
||||||
|
- name: Push chart to GHCR
|
||||||
|
env:
|
||||||
|
CHART_REPO: ${{ env.CHART_REPO }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
helm push "dist/buzz-${VERSION}.tgz" "$CHART_REPO"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
CHART_REPO: ${{ env.CHART_REPO }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# CHART_REPO is oci://ghcr.io/block/buzz/charts; helm push appends the
|
||||||
|
# chart name, so the install ref is .../charts/buzz.
|
||||||
|
INSTALL_REF="${CHART_REPO}/buzz"
|
||||||
|
{
|
||||||
|
echo "### Published chart \`buzz\` \`${VERSION}\`"
|
||||||
|
echo
|
||||||
|
echo "**OCI ref:** \`${INSTALL_REF}:${VERSION}\`"
|
||||||
|
echo
|
||||||
|
echo "Install:"
|
||||||
|
echo '```'
|
||||||
|
echo "helm install buzz ${INSTALL_REF} --version ${VERSION}"
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
name: Linux Canary
|
||||||
|
|
||||||
|
# Produces unsigned Linux .deb and .AppImage packages from main without
|
||||||
|
# creating a tag, GitHub Release, or auto-updater artifact. Artifacts are
|
||||||
|
# available as a short-lived GitHub Actions artifact for explicit testing.
|
||||||
|
#
|
||||||
|
# Design notes vs. signed-macos-canary.yml:
|
||||||
|
# - fix-appimage.sh is run without signing env vars; the script detects
|
||||||
|
# their absence and skips re-signing, repacking only (documented inline).
|
||||||
|
# - Build tools match release.yml; cache keys derive the concrete linker and
|
||||||
|
# native library identity rather than assuming the moving runner image.
|
||||||
|
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Linux canary
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
# AppImage tools are themselves AppImages; containers lack FUSE so we
|
||||||
|
# must use the extract-and-run fallback.
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Require main
|
||||||
|
env:
|
||||||
|
SOURCE_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
|
||||||
|
echo "::error::Canary builds must run from main; got $SOURCE_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
run: |
|
||||||
|
apt-get update \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30 \
|
||||||
|
-o DPkg::Lock::Timeout=120 \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
desktop-file-utils \
|
||||||
|
file \
|
||||||
|
git \
|
||||||
|
libasound2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libxdo-dev \
|
||||||
|
patchelf \
|
||||||
|
pkg-config \
|
||||||
|
squashfs-tools \
|
||||||
|
wget \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Mark workspace safe for git (containerized job)
|
||||||
|
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Install appimagetool
|
||||||
|
run: |
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||||
|
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Unsupported architecture: $(uname -m)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \
|
||||||
|
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage"
|
||||||
|
if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then
|
||||||
|
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c
|
||||||
|
else
|
||||||
|
echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool
|
||||||
|
wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \
|
||||||
|
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}"
|
||||||
|
echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c
|
||||||
|
install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime
|
||||||
|
echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restore pnpm store cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Derive canary version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
|
||||||
|
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||||
|
echo "::error::Desktop version '$BASE_VERSION' is not semver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building canary version $VERSION from $GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Patch canary version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Compute this after cargo update so the key describes the graph that is
|
||||||
|
# actually compiled. The helper normalizes only Buzz Desktop's release
|
||||||
|
# version, allowing a canary to warm an otherwise identical tag build.
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py \
|
||||||
|
--platform "$RUNNER_OS" \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--features mesh-llm \
|
||||||
|
--native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release cache key: $KEY"
|
||||||
|
|
||||||
|
- name: Restore exact release Cargo cache
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Generate non-updating bundle config
|
||||||
|
run: |
|
||||||
|
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"createUpdaterArtifacts": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh
|
||||||
|
|
||||||
|
- name: Build Linux Tauri app
|
||||||
|
run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json
|
||||||
|
env:
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
|
||||||
|
- name: Fix AppImage (remove infra libs, symlink system GStreamer)
|
||||||
|
# fix-appimage.sh checks for TAURI_SIGNING_PRIVATE_KEY and skips
|
||||||
|
# re-signing when absent, so no signing env vars are needed here.
|
||||||
|
# Repacking still runs, removing the Mesa/GLib/GStreamer conflict libs.
|
||||||
|
run: |
|
||||||
|
mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f)
|
||||||
|
if [[ ${#APPIMAGES[@]} -eq 0 ]]; then
|
||||||
|
echo "::error::No AppImage found to post-process"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ${#APPIMAGES[@]} -gt 1 ]]; then
|
||||||
|
echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
|
||||||
|
|
||||||
|
- name: Measure release Cargo cache inputs
|
||||||
|
if: always()
|
||||||
|
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
|
||||||
|
|
||||||
|
# Only this trusted, main-bound canary writes the cache. Excluding bundle
|
||||||
|
# output prevents installers from entering it.
|
||||||
|
- name: Save exact release Cargo cache
|
||||||
|
if: steps.rust_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Save pnpm store cache
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Locate Linux build artifacts
|
||||||
|
id: artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||||
|
|
||||||
|
DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1)
|
||||||
|
if [[ -z "$DEB" ]]; then
|
||||||
|
echo "::error::No DEB found in $BUNDLE_DIR/deb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1)
|
||||||
|
if [[ -z "$APPIMAGE" ]]; then
|
||||||
|
echo "::error::No AppImage found in $BUNDLE_DIR/appimage"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload Linux canary packages
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: buzz-linux-canary-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
${{ steps.artifacts.outputs.deb }}
|
||||||
|
${{ steps.artifacts.outputs.appimage }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: macOS Intel Canary
|
||||||
|
|
||||||
|
# Produces an unsigned Intel DMG from trusted main. Its release-equivalent
|
||||||
|
# Cargo state warms the distinct x86_64 release target without signing or
|
||||||
|
# publishing anything.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build macOS Intel canary
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
TARGET: x86_64-apple-darwin
|
||||||
|
steps:
|
||||||
|
- name: Require main
|
||||||
|
env:
|
||||||
|
SOURCE_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
|
||||||
|
echo "::error::Canary builds must run from main; got $SOURCE_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Add Rust target
|
||||||
|
run: rustup target add "$TARGET"
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Derive and patch canary version
|
||||||
|
run: |
|
||||||
|
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
|
||||||
|
VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}"
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py \
|
||||||
|
--platform "$RUNNER_OS" \
|
||||||
|
--target "$TARGET" \
|
||||||
|
--features default \
|
||||||
|
--native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release cache key: $KEY"
|
||||||
|
|
||||||
|
- name: Restore exact release Cargo cache
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Generate non-updating bundle config
|
||||||
|
run: |
|
||||||
|
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
|
||||||
|
{"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
- name: Build Intel sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh "$TARGET"
|
||||||
|
|
||||||
|
- name: Build unsigned Intel DMG
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json
|
||||||
|
env:
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||||
|
|
||||||
|
- name: Locate fresh Intel DMG
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1)
|
||||||
|
[[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; }
|
||||||
|
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload Intel canary
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: buzz-macos-intel-canary-${{ github.sha }}
|
||||||
|
path: ${{ steps.artifact.outputs.dmg }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Measure release Cargo cache inputs
|
||||||
|
if: always()
|
||||||
|
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Save exact release Cargo cache
|
||||||
|
if: steps.rust_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Mesh Lifecycle
|
||||||
|
# Relay-driven mesh lifecycle smoke: membership → signed discovery notes →
|
||||||
|
# relay-derived allowlist → join → CPU inference over QUIC → stranger denied
|
||||||
|
# (relay membership rejection + no routed inference, with a differential
|
||||||
|
# trusted-inference health proof so a dead serve node can't fake a denial).
|
||||||
|
# Runs the full Buzz "shared compute" join story with three real mesh-llm
|
||||||
|
# node processes on one runner, using the Buzz relay as the control plane
|
||||||
|
# (no hand-carried invite tokens). Mirrors the shape mesh-llm's own CI uses
|
||||||
|
# for its two-node smokes (tiny CPU model, one runner, real QUIC mesh).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'crates/buzz-relay/examples/mesh_*.rs'
|
||||||
|
- 'crates/buzz-relay/Cargo.toml'
|
||||||
|
- 'crates/buzz-admin/**'
|
||||||
|
- 'crates/buzz-test-client/**'
|
||||||
|
- 'crates/buzz-ws-client/**'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'desktop/src-tauri/src/mesh_llm/**'
|
||||||
|
- 'scripts/ci-mesh-lifecycle-smoke.sh'
|
||||||
|
- 'scripts/start-relay-for-tests.sh'
|
||||||
|
- '.github/workflows/mesh-lifecycle.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'crates/buzz-relay/examples/mesh_*.rs'
|
||||||
|
- 'crates/buzz-relay/Cargo.toml'
|
||||||
|
- 'crates/buzz-admin/**'
|
||||||
|
- 'crates/buzz-test-client/**'
|
||||||
|
- 'crates/buzz-ws-client/**'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'desktop/src-tauri/src/mesh_llm/**'
|
||||||
|
- 'scripts/ci-mesh-lifecycle-smoke.sh'
|
||||||
|
- 'scripts/start-relay-for-tests.sh'
|
||||||
|
- '.github/workflows/mesh-lifecycle.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mesh-lifecycle-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lifecycle-smoke:
|
||||||
|
name: Relay-Driven Mesh Lifecycle Smoke
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 45
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
with:
|
||||||
|
save-if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|
||||||
|
# The mesh-llm SDK downloads a signed native runtime (llama.cpp CPU
|
||||||
|
# build) on first init, and the serve node downloads the smoke model
|
||||||
|
# from HuggingFace on first run. Key on the lockfile so a mesh pin bump
|
||||||
|
# rolls the runtime cache; the model ref is stable.
|
||||||
|
- name: Restore mesh runtime + model caches
|
||||||
|
id: mesh-caches
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/mesh-llm/native-runtimes
|
||||||
|
~/.cache/huggingface/hub
|
||||||
|
key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
mesh-lifecycle-${{ runner.os }}-smollm2-135m-
|
||||||
|
|
||||||
|
- name: Start integration services
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if docker compose up -d postgres redis minio minio-init; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "docker compose up failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2
|
||||||
|
sleep $((attempt * 5))
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run relay-driven mesh lifecycle smoke
|
||||||
|
run: ./scripts/ci-mesh-lifecycle-smoke.sh 2>&1 | tee /tmp/mesh-lifecycle-harness.log
|
||||||
|
|
||||||
|
- name: Save mesh runtime + model caches
|
||||||
|
if: github.ref == 'refs/heads/main' && steps.mesh-caches.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/mesh-llm/native-runtimes
|
||||||
|
~/.cache/huggingface/hub
|
||||||
|
key: mesh-lifecycle-${{ runner.os }}-smollm2-135m-${{ hashFiles('Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Upload relay + harness logs
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: mesh-lifecycle-logs
|
||||||
|
path: |
|
||||||
|
/tmp/buzz-relay.log
|
||||||
|
/tmp/mesh-lifecycle-harness.log
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Publish Mobile Release Candidate
|
||||||
|
run-name: Publish mobile-v${{ inputs.version }}-rc.${{ inputs.candidate_number }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Mobile marketing version (X.Y.Z)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
candidate_number:
|
||||||
|
description: Expected next release-candidate number
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
target_sha:
|
||||||
|
description: Exact current block/buzz main commit
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: mobile-release-candidate-${{ inputs.version }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Require the reviewed workflow from main
|
||||||
|
env:
|
||||||
|
DISPATCH_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [ "$DISPATCH_REF" != "refs/heads/main" ]; then
|
||||||
|
echo "::error::Mobile candidates must be dispatched from main, not $DISPATCH_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Require canonical repository
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
if [ "$REPOSITORY" != "block/buzz" ]; then
|
||||||
|
echo "::error::Mobile candidate publication is restricted to block/buzz"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create release tagger token
|
||||||
|
id: release-tagger
|
||||||
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||||
|
with:
|
||||||
|
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
|
||||||
|
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
|
||||||
|
permission-contents: write
|
||||||
|
|
||||||
|
- name: Publish annotated candidate tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
|
||||||
|
MOBILE_VERSION: ${{ inputs.version }}
|
||||||
|
CANDIDATE_NUMBER: ${{ inputs.candidate_number }}
|
||||||
|
TARGET_SHA: ${{ inputs.target_sha }}
|
||||||
|
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: push gateway helm chart
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Chart semver (without push-chart-v prefix)"
|
||||||
|
required: true
|
||||||
|
ref:
|
||||||
|
description: "Matching push-chart-v tag"
|
||||||
|
required: true
|
||||||
|
push:
|
||||||
|
tags: ["push-chart-v[0-9]*"]
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "deploy/charts/buzz-push-gateway/**"
|
||||||
|
- ".github/workflows/push-gateway-helm-chart.yml"
|
||||||
|
permissions: {}
|
||||||
|
env:
|
||||||
|
CHART_REPO: oci://ghcr.io/block/buzz/charts
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: { contents: read }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
|
||||||
|
with: { version: v3.16.4 }
|
||||||
|
- run: deploy/charts/buzz-push-gateway/tests/render.sh
|
||||||
|
- run: deploy/charts/buzz-push-gateway/tests/release-contract.sh
|
||||||
|
publish:
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
needs: validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4
|
||||||
|
with: { version: v3.16.4 }
|
||||||
|
- name: Verify release tag and chart version
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"
|
||||||
|
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]
|
||||||
|
test "$(helm show chart deploy/charts/buzz-push-gateway | awk '/^version:/ {print $2}')" = "$version"
|
||||||
|
if [ -n "$INPUT_VERSION" ]; then
|
||||||
|
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/push-chart-v${version}^{commit}")"
|
||||||
|
fi
|
||||||
|
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||||
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- run: helm package deploy/charts/buzz-push-gateway --destination dist
|
||||||
|
- run: helm push "dist/buzz-push-gateway-${VERSION}.tgz" "$CHART_REPO"
|
||||||
@@ -0,0 +1,952 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'desktop-v[0-9]*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Shared setup: verify the immutable release tag, determine the version, and
|
||||||
|
# create the release objects all four platform jobs upload into.
|
||||||
|
setup:
|
||||||
|
name: Setup
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
source_sha: ${{ steps.source.outputs.source_sha }}
|
||||||
|
steps:
|
||||||
|
- name: Determine version
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validate version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
id: source
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: setup
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # required by block/apple-codesign-action for OIDC
|
||||||
|
outputs:
|
||||||
|
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||||
|
sig: ${{ steps.read-sig.outputs.sig }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Patch version
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Generate release config
|
||||||
|
run: cd desktop && node scripts/build-release-config.mjs
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh
|
||||||
|
|
||||||
|
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
|
||||||
|
- name: Resolve mesh-llm rev
|
||||||
|
id: mesh_rev
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
|
||||||
|
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||||
|
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore mesh llama build cache
|
||||||
|
id: llama_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||||
|
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||||
|
- name: Build mesh llama native libraries
|
||||||
|
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||||
|
SHORT="$MESH_REV_SHORT"
|
||||||
|
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
|
||||||
|
if [[ -z "$MESH_ROOT" ]]; then
|
||||||
|
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export LLAMA_STAGE_BACKEND=metal
|
||||||
|
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||||
|
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||||
|
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||||
|
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||||
|
- name: Save mesh llama build cache
|
||||||
|
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||||
|
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||||
|
|
||||||
|
- name: Build unsigned Tauri app
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
LLAMA_STAGE_BACKEND: metal
|
||||||
|
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||||
|
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||||
|
|
||||||
|
- name: Locate unsigned DMG
|
||||||
|
id: unsigned
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||||
|
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||||
|
if [[ -z "$DMG" ]]; then
|
||||||
|
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set DMG Finder label text size
|
||||||
|
env:
|
||||||
|
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
|
||||||
|
|
||||||
|
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
|
||||||
|
- name: Stage signing entitlements
|
||||||
|
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
|
||||||
|
|
||||||
|
- name: Codesign and Notarize
|
||||||
|
id: codesign
|
||||||
|
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
|
||||||
|
with:
|
||||||
|
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
|
||||||
|
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
|
||||||
|
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
entitlements-plist-path: ${{ runner.temp }}/entitlements.plist
|
||||||
|
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-arm64
|
||||||
|
|
||||||
|
- name: Replace DMG and rebuild updater archive
|
||||||
|
env:
|
||||||
|
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
|
||||||
|
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
|
||||||
|
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||||
|
APP_DIR="${BUNDLE_DIR}/macos"
|
||||||
|
|
||||||
|
# Replace unsigned DMG with the signed/notarized one.
|
||||||
|
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
|
||||||
|
|
||||||
|
# Swap the unsigned .app for the signed .app extracted from the action's zip.
|
||||||
|
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
|
||||||
|
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
|
||||||
|
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
|
||||||
|
rm -rf "${APP_DIR}/Buzz.app"
|
||||||
|
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
|
||||||
|
|
||||||
|
# Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key.
|
||||||
|
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
|
||||||
|
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
|
||||||
|
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
|
||||||
|
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
|
||||||
|
|
||||||
|
- name: Verify code signature
|
||||||
|
run: |
|
||||||
|
codesign --verify --deep --strict --verbose=2 \
|
||||||
|
desktop/src-tauri/target/release/bundle/macos/Buzz.app
|
||||||
|
spctl --assess --type execute --verbose=4 \
|
||||||
|
desktop/src-tauri/target/release/bundle/macos/Buzz.app
|
||||||
|
desktop/scripts/verify-macos-entitlements.sh \
|
||||||
|
desktop/src-tauri/target/release/bundle/macos/Buzz.app
|
||||||
|
|
||||||
|
- name: Locate build artifacts
|
||||||
|
id: artifacts
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||||
|
|
||||||
|
# Find the DMG (Tauri names it Buzz_<version>_<arch>.dmg)
|
||||||
|
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||||
|
if [[ -z "$DMG" ]]; then
|
||||||
|
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Find the updater .tar.gz and .sig. Give each architecture a unique
|
||||||
|
# release basename before artifacts are merged by the final writer.
|
||||||
|
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||||
|
SIG="${ARCHIVE}.sig"
|
||||||
|
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||||
|
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz"
|
||||||
|
mv "$ARCHIVE" "$RENAMED"
|
||||||
|
mv "$SIG" "${RENAMED}.sig"
|
||||||
|
ARCHIVE="$RENAMED"
|
||||||
|
SIG="${RENAMED}.sig"
|
||||||
|
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Read updater signature
|
||||||
|
id: read-sig
|
||||||
|
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
- name: Stage Apple Silicon release artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: desktop-release-macos-arm64
|
||||||
|
if-no-files-found: error
|
||||||
|
path: |
|
||||||
|
${{ steps.artifacts.outputs.dmg }}
|
||||||
|
${{ steps.artifacts.outputs.archive }}
|
||||||
|
${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
release-macos-x64:
|
||||||
|
name: Release macOS (Intel)
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: setup
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # required by block/apple-codesign-action for OIDC
|
||||||
|
outputs:
|
||||||
|
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||||
|
sig: ${{ steps.read-sig.outputs.sig }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
TARGET: x86_64-apple-darwin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Add Rust target
|
||||||
|
run: rustup target add "$TARGET"
|
||||||
|
|
||||||
|
- name: Patch version
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Generate release config
|
||||||
|
run: cd desktop && node scripts/build-release-config.mjs
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh "$TARGET"
|
||||||
|
|
||||||
|
- name: Build unsigned Tauri app
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||||
|
|
||||||
|
- name: Locate unsigned DMG
|
||||||
|
id: unsigned
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||||
|
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
|
||||||
|
if [[ -z "$DMG" ]]; then
|
||||||
|
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set DMG Finder label text size
|
||||||
|
env:
|
||||||
|
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
|
||||||
|
|
||||||
|
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
|
||||||
|
- name: Stage signing entitlements
|
||||||
|
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
|
||||||
|
|
||||||
|
- name: Codesign and Notarize
|
||||||
|
id: codesign
|
||||||
|
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
|
||||||
|
with:
|
||||||
|
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
|
||||||
|
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
|
||||||
|
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
entitlements-plist-path: ${{ runner.temp }}/entitlements.plist
|
||||||
|
artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-x64
|
||||||
|
|
||||||
|
- name: Replace DMG and rebuild updater archive
|
||||||
|
env:
|
||||||
|
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
|
||||||
|
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
|
||||||
|
UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos"
|
||||||
|
|
||||||
|
# Replace the unsigned DMG with the signed/notarized one.
|
||||||
|
cp "$SIGNED_DMG" "$UNSIGNED_DMG"
|
||||||
|
|
||||||
|
# Swap the unsigned .app for the signed .app from the action's zip.
|
||||||
|
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract-x64"
|
||||||
|
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
|
||||||
|
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
|
||||||
|
rm -rf "${APP_DIR}/Buzz.app"
|
||||||
|
cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app"
|
||||||
|
|
||||||
|
# Rebuild the updater archive from the signed .app and re-sign with the Tauri updater key.
|
||||||
|
rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig"
|
||||||
|
(cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app)
|
||||||
|
TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz"
|
||||||
|
(cd desktop && pnpm tauri signer sign "$TARBALL_ABS")
|
||||||
|
|
||||||
|
- name: Verify code signature
|
||||||
|
run: |
|
||||||
|
APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$APP_DIR"
|
||||||
|
spctl --assess --type execute --verbose=4 "$APP_DIR"
|
||||||
|
desktop/scripts/verify-macos-entitlements.sh "$APP_DIR"
|
||||||
|
|
||||||
|
- name: Locate updater archive
|
||||||
|
id: artifacts
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||||
|
|
||||||
|
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||||
|
SIG="${ARCHIVE}.sig"
|
||||||
|
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||||
|
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz"
|
||||||
|
mv "$ARCHIVE" "$RENAMED"
|
||||||
|
mv "$SIG" "${RENAMED}.sig"
|
||||||
|
ARCHIVE="$RENAMED"
|
||||||
|
SIG="${RENAMED}.sig"
|
||||||
|
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Read updater signature
|
||||||
|
id: read-sig
|
||||||
|
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
- name: Stage Intel macOS release artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: desktop-release-macos-x64
|
||||||
|
if-no-files-found: error
|
||||||
|
path: |
|
||||||
|
${{ steps.unsigned.outputs.dmg }}
|
||||||
|
${{ steps.artifacts.outputs.archive }}
|
||||||
|
${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
release-linux:
|
||||||
|
name: Release Linux
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh.
|
||||||
|
container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
|
||||||
|
needs: setup
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
# AppImage tools (linuxdeploy, appimagetool) are themselves AppImages.
|
||||||
|
# Containers lack FUSE, so we must use the extract-and-run fallback.
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
|
# This job runs in a container where the default run shell is dash;
|
||||||
|
# the AppImage steps below use bash-only syntax ([[ ]], mapfile, arrays).
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
outputs:
|
||||||
|
archive_name: ${{ steps.linux-artifacts.outputs.archive_name }}
|
||||||
|
sig: ${{ steps.read-sig.outputs.sig }}
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
env:
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
run: |
|
||||||
|
# Must run first: bare ubuntu:24.04 ships without curl, wget, git, or
|
||||||
|
# ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs
|
||||||
|
# both), and actions/checkout falls back to a REST tarball without git.
|
||||||
|
# Running as root — no sudo needed.
|
||||||
|
apt-get update \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30 \
|
||||||
|
-o DPkg::Lock::Timeout=120 \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
desktop-file-utils \
|
||||||
|
file \
|
||||||
|
git \
|
||||||
|
libasound2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libxdo-dev \
|
||||||
|
patchelf \
|
||||||
|
pkg-config \
|
||||||
|
squashfs-tools \
|
||||||
|
wget \
|
||||||
|
xdg-utils
|
||||||
|
# Install GitHub CLI — preinstalled on runners but absent in containers.
|
||||||
|
# wget and ca-certificates are now available from the step above.
|
||||||
|
mkdir -p -m 755 /etc/apt/keyrings
|
||||||
|
wget -q --tries=3 --timeout=30 -O /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
https://cli.github.com/packages/githubcli-archive-keyring.gpg
|
||||||
|
# Pin the keyring like appimagetool below. If GitHub rotates the
|
||||||
|
# keyring this fails loudly — recompute and update the hash.
|
||||||
|
echo "6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b /usr/share/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c
|
||||||
|
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
|
> /etc/apt/sources.list.d/github-cli.list
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends gh
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Mark workspace safe for git (containerized job)
|
||||||
|
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
with:
|
||||||
|
workspaces: desktop/src-tauri
|
||||||
|
lookup-only: true
|
||||||
|
|
||||||
|
- name: Install appimagetool
|
||||||
|
run: |
|
||||||
|
# Pin to an immutable release tag to avoid supply-chain drift from the
|
||||||
|
# mutable `continuous` tag. Tag: 1.9.1, asset: appimagetool-<arch>.AppImage
|
||||||
|
# (https://github.com/AppImage/appimagetool/releases/tag/1.9.1)
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH_SUFFIX="x86_64" ;;
|
||||||
|
aarch64) ARCH_SUFFIX="aarch64" ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Unsupported architecture: $(uname -m)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
wget -q --tries=3 --timeout=30 -O /tmp/appimagetool \
|
||||||
|
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${ARCH_SUFFIX}.AppImage"
|
||||||
|
# SHA256 integrity check. Refuse to run an unverified binary: if a new
|
||||||
|
# arch (e.g. aarch64) is enabled in CI, compute its hash and add it here.
|
||||||
|
if [[ "$ARCH_SUFFIX" == "x86_64" ]]; then
|
||||||
|
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | sha256sum -c
|
||||||
|
else
|
||||||
|
echo "::error::No pinned SHA256 for appimagetool-${ARCH_SUFFIX} — add it before enabling this architecture"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -m 755 /tmp/appimagetool /usr/local/bin/appimagetool
|
||||||
|
# appimagetool otherwise fetches the AppImage type2 runtime from the
|
||||||
|
# MUTABLE `continuous` tag at repack time — the runtime is the first
|
||||||
|
# code users execute, so pin it too. Tag: 20251108, hash is for the
|
||||||
|
# x86_64 asset (non-x86_64 already hard-fails above).
|
||||||
|
# (https://github.com/AppImage/type2-runtime/releases/tag/20251108)
|
||||||
|
wget -q --tries=3 --timeout=30 -O /tmp/appimage-runtime \
|
||||||
|
"https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${ARCH_SUFFIX}"
|
||||||
|
echo "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d /tmp/appimage-runtime" | sha256sum -c
|
||||||
|
install -D -m 644 /tmp/appimage-runtime /usr/local/lib/appimage-runtime
|
||||||
|
echo "APPIMAGETOOL_RUNTIME_FILE=/usr/local/lib/appimage-runtime" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Patch version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh
|
||||||
|
|
||||||
|
- name: Generate release config
|
||||||
|
run: cd desktop && node scripts/build-release-config.mjs
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
|
||||||
|
- name: Build Linux Tauri app
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.release.conf.json
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Fix AppImage (remove infra libs, shim host GStreamer)
|
||||||
|
run: |
|
||||||
|
mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f)
|
||||||
|
if [[ ${#APPIMAGES[@]} -eq 0 ]]; then
|
||||||
|
echo "::error::No AppImage found to post-process"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ${#APPIMAGES[@]} -gt 1 ]]; then
|
||||||
|
echo "::error::Expected exactly one AppImage, found ${#APPIMAGES[@]}: ${APPIMAGES[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}"
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Locate Linux build artifacts
|
||||||
|
id: linux-artifacts
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
|
||||||
|
|
||||||
|
DEB=$(find "$BUNDLE_DIR/deb" -name '*.deb' -type f | head -1)
|
||||||
|
if [[ -z "$DEB" ]]; then
|
||||||
|
echo "::error::No DEB found in $BUNDLE_DIR/deb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage' -type f | head -1)
|
||||||
|
if [[ -z "$APPIMAGE" ]]; then
|
||||||
|
echo "::error::No AppImage found in $BUNDLE_DIR/appimage"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Updater archive: Tauri 2.11+ with createUpdaterArtifacts signs the
|
||||||
|
# AppImage directly (*.AppImage + *.AppImage.sig). Earlier versions
|
||||||
|
# wrapped it in a tar.gz. Try the new format first, fall back to legacy.
|
||||||
|
ARCHIVE=$(find "$BUNDLE_DIR/appimage" -name '*.AppImage.tar.gz' ! -name '*.sig' -type f | head -1)
|
||||||
|
if [[ -n "$ARCHIVE" ]]; then
|
||||||
|
SIG="${ARCHIVE}.sig"
|
||||||
|
else
|
||||||
|
ARCHIVE="$APPIMAGE"
|
||||||
|
SIG="${APPIMAGE}.sig"
|
||||||
|
fi
|
||||||
|
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
|
||||||
|
echo "::error::AppImage updater archive or signature not found in $BUNDLE_DIR/appimage"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Read updater signature
|
||||||
|
id: read-sig
|
||||||
|
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
# NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux)
|
||||||
|
- name: Stage Linux release artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: desktop-release-linux-x64
|
||||||
|
if-no-files-found: error
|
||||||
|
path: |
|
||||||
|
${{ steps.linux-artifacts.outputs.deb }}
|
||||||
|
${{ steps.linux-artifacts.outputs.appimage }}
|
||||||
|
${{ steps.linux-artifacts.outputs.archive }}
|
||||||
|
${{ steps.linux-artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
release-windows:
|
||||||
|
name: Release Windows
|
||||||
|
runs-on: windows-latest
|
||||||
|
needs: setup
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
archive_name: ${{ steps.artifacts.outputs.archive_name }}
|
||||||
|
sig: ${{ steps.read-sig.outputs.sig }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
TARGET: x86_64-pc-windows-msvc
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
shell: bash
|
||||||
|
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
|
||||||
|
with:
|
||||||
|
targets: ${{ env.TARGET }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 24.14.1
|
||||||
|
# Disable dependency caching: a writable cache in this release workflow
|
||||||
|
# (contents: read, feeds a signed installer) is a poisoning vector. pnpm
|
||||||
|
# install runs uncached below.
|
||||||
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
with:
|
||||||
|
version: 11.4.0
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
shell: bash
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Patch version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Generate release config
|
||||||
|
shell: bash
|
||||||
|
run: cd desktop && node scripts/build-release-config.mjs
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh "$TARGET"
|
||||||
|
|
||||||
|
- name: Build Windows NSIS installer (unsigned)
|
||||||
|
shell: bash
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json
|
||||||
|
env:
|
||||||
|
BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }}
|
||||||
|
BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
|
||||||
|
- name: Locate Windows build artifacts
|
||||||
|
id: artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||||
|
|
||||||
|
# Find the NSIS installer .exe
|
||||||
|
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
|
||||||
|
if [[ -z "$EXE" ]]; then
|
||||||
|
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tauri 2.x with createUpdaterArtifacts: true signs the NSIS
|
||||||
|
# installer in place (<name>-setup.exe + <name>-setup.exe.sig).
|
||||||
|
SIG="${EXE}.sig"
|
||||||
|
if [[ ! -f "$SIG" ]]; then
|
||||||
|
echo "::error::NSIS installer signature not found: $SIG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename with _alpha-unsigned marker, keeping the detached signature
|
||||||
|
# in lockstep so latest.json matches the uploaded updater artifact.
|
||||||
|
EXE_DIR=$(dirname "$EXE")
|
||||||
|
EXE_BASE=$(basename "$EXE" .exe)
|
||||||
|
MARKED_EXE="${EXE_DIR}/${EXE_BASE}_alpha-unsigned.exe"
|
||||||
|
MARKED_SIG="${MARKED_EXE}.sig"
|
||||||
|
mv "$EXE" "$MARKED_EXE"
|
||||||
|
mv "$SIG" "$MARKED_SIG"
|
||||||
|
echo "exe=$MARKED_EXE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive=$MARKED_EXE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive_name=$(basename "$MARKED_EXE")" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sig=$MARKED_SIG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Read updater signature
|
||||||
|
id: read-sig
|
||||||
|
shell: bash
|
||||||
|
run: echo "sig=$(cat "$SIG_PATH")" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
- name: Stage Windows release artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: desktop-release-windows-x64
|
||||||
|
if-no-files-found: error
|
||||||
|
path: |
|
||||||
|
${{ steps.artifacts.outputs.exe }}
|
||||||
|
${{ steps.artifacts.outputs.sig }}
|
||||||
|
|
||||||
|
assemble-manifest:
|
||||||
|
name: Assemble multi-platform latest.json
|
||||||
|
# Only the tag-bound setup path can reach this job.
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
needs.setup.result == 'success' &&
|
||||||
|
needs.release.result == 'success' &&
|
||||||
|
needs.release-macos-x64.result == 'success' &&
|
||||||
|
needs.release-linux.result == 'success' &&
|
||||||
|
needs.release-windows.result == 'success' &&
|
||||||
|
github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [setup, release, release-macos-x64, release-linux, release-windows]
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.setup.outputs.version }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.source_sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify tag-bound release source
|
||||||
|
run: scripts/verify-release-ref.sh desktop-v "$VERSION"
|
||||||
|
|
||||||
|
- name: Download staged release artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
pattern: desktop-release-*
|
||||||
|
path: staged-by-platform
|
||||||
|
|
||||||
|
- name: Flatten staged artifacts without basename collisions
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir staged
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
name="$(basename "$file")"
|
||||||
|
[[ ! -e "staged/$name" ]] || {
|
||||||
|
echo "::error::release artifact basename collision: $name"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
cp "$file" "staged/$name"
|
||||||
|
done < <(find staged-by-platform -type f -print0)
|
||||||
|
|
||||||
|
- name: Write signature files
|
||||||
|
env:
|
||||||
|
RESULT_ARM64: ${{ needs.release.result }}
|
||||||
|
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||||
|
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||||
|
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||||
|
SIG_ARM64: ${{ needs.release.outputs.sig }}
|
||||||
|
SIG_X64: ${{ needs.release-macos-x64.outputs.sig }}
|
||||||
|
SIG_LINUX: ${{ needs.release-linux.outputs.sig }}
|
||||||
|
SIG_WIN: ${{ needs.release-windows.outputs.sig }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/sigs
|
||||||
|
|
||||||
|
write_sig() {
|
||||||
|
local result="$1" platform="$2" sig="$3"
|
||||||
|
if [[ "$result" == "success" ]]; then
|
||||||
|
[[ -n "$sig" ]] || { echo "::error::Missing signature for successful platform: $platform"; exit 1; }
|
||||||
|
printf '%s' "$sig" > "/tmp/sigs/${platform}.sig"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_sig "$RESULT_ARM64" darwin-aarch64 "$SIG_ARM64"
|
||||||
|
write_sig "$RESULT_X64" darwin-x86_64 "$SIG_X64"
|
||||||
|
write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX"
|
||||||
|
write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN"
|
||||||
|
|
||||||
|
- name: Verify draft release has every updater archive
|
||||||
|
env:
|
||||||
|
RESULT_ARM64: ${{ needs.release.result }}
|
||||||
|
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||||
|
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||||
|
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||||
|
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
|
||||||
|
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
|
||||||
|
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
|
||||||
|
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
assets=$(find staged -type f -exec basename {} \;)
|
||||||
|
for spec in \
|
||||||
|
"$RESULT_ARM64:$ARCHIVE_ARM64" \
|
||||||
|
"$RESULT_X64:$ARCHIVE_X64" \
|
||||||
|
"$RESULT_LINUX:$ARCHIVE_LINUX" \
|
||||||
|
"$RESULT_WIN:$ARCHIVE_WIN"; do
|
||||||
|
result="${spec%%:*}"
|
||||||
|
archive="${spec#*:}"
|
||||||
|
if [[ "$result" == success ]]; then
|
||||||
|
[[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; }
|
||||||
|
grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; }
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Generate unified latest.json
|
||||||
|
env:
|
||||||
|
RESULT_ARM64: ${{ needs.release.result }}
|
||||||
|
RESULT_X64: ${{ needs.release-macos-x64.result }}
|
||||||
|
RESULT_LINUX: ${{ needs.release-linux.result }}
|
||||||
|
RESULT_WIN: ${{ needs.release-windows.result }}
|
||||||
|
ARCHIVE_ARM64: ${{ needs.release.outputs.archive_name }}
|
||||||
|
ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }}
|
||||||
|
ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }}
|
||||||
|
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}"
|
||||||
|
TRIPLES=()
|
||||||
|
|
||||||
|
add_triple() {
|
||||||
|
local result="$1" platform="$2" archive="$3"
|
||||||
|
if [[ "$result" == "success" ]]; then
|
||||||
|
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
|
||||||
|
TRIPLES+=("${platform}:/tmp/sigs/${platform}.sig:${BASE}/${archive}")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_triple "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
|
||||||
|
add_triple "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
|
||||||
|
add_triple "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
|
||||||
|
add_triple "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
|
||||||
|
|
||||||
|
[ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; }
|
||||||
|
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
|
||||||
|
cat latest.json
|
||||||
|
|
||||||
|
- name: Create or verify versioned draft
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
|
||||||
|
awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE"
|
||||||
|
[[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; }
|
||||||
|
PRERELEASE_FLAGS=()
|
||||||
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
PRERELEASE_FLAGS=(--prerelease --latest=false)
|
||||||
|
fi
|
||||||
|
if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then
|
||||||
|
EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish)
|
||||||
|
IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft)
|
||||||
|
[[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || {
|
||||||
|
echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1;
|
||||||
|
}
|
||||||
|
if [[ "$IS_DRAFT" != true ]]; then
|
||||||
|
echo "already_published=true" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
gh release create "desktop-v${VERSION}" \
|
||||||
|
--draft \
|
||||||
|
--target "${{ needs.setup.outputs.source_sha }}" \
|
||||||
|
--title "Buzz Desktop v${VERSION}" \
|
||||||
|
--notes-file "$NOTES_FILE" \
|
||||||
|
"${PRERELEASE_FLAGS[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload complete artifact set to versioned draft
|
||||||
|
if: env.already_published != 'true'
|
||||||
|
run: |
|
||||||
|
mapfile -t files < <(find staged -type f -print)
|
||||||
|
[[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; }
|
||||||
|
gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber
|
||||||
|
|
||||||
|
- name: Publish complete versioned release
|
||||||
|
if: env.already_published != 'true'
|
||||||
|
run: gh release edit "desktop-v${VERSION}" --draft=false
|
||||||
|
|
||||||
|
- name: Upload latest.json to rolling release last
|
||||||
|
if: ${{ !contains(needs.setup.outputs.version, '-') }}
|
||||||
|
run: gh release upload buzz-desktop-latest latest.json --clobber
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
name: Signed macOS Canary
|
||||||
|
|
||||||
|
# Produces a signed and notarized Apple Silicon DMG from main without creating
|
||||||
|
# a tag, GitHub Release, or auto-updater artifact. The DMG is available only as
|
||||||
|
# a short-lived GitHub Actions artifact for explicit testing.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build signed macOS canary
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # required by block/apple-codesign-action for OIDC
|
||||||
|
steps:
|
||||||
|
- name: Require main
|
||||||
|
env:
|
||||||
|
SOURCE_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
|
||||||
|
echo "::error::Signed canary builds must run from main; got $SOURCE_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restore pnpm store cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
run: just desktop-install-ci
|
||||||
|
|
||||||
|
- name: Derive canary version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
|
||||||
|
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||||
|
echo "::error::Desktop version '$BASE_VERSION' is not semver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building canary version $VERSION from $GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Patch canary version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Compute this after cargo update so the key describes the graph that is
|
||||||
|
# actually compiled. The helper normalizes only Buzz Desktop's release
|
||||||
|
# version, allowing a canary to warm an otherwise identical tag build.
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py \
|
||||||
|
--platform "$RUNNER_OS" \
|
||||||
|
--target aarch64-apple-darwin \
|
||||||
|
--features mesh-llm \
|
||||||
|
--native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release cache key: $KEY"
|
||||||
|
|
||||||
|
- name: Restore exact release Cargo cache
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Generate non-updating bundle config
|
||||||
|
run: |
|
||||||
|
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
},
|
||||||
|
"createUpdaterArtifacts": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
run: |
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh
|
||||||
|
|
||||||
|
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
|
||||||
|
- name: Resolve mesh-llm rev
|
||||||
|
id: mesh_rev
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REV=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])')
|
||||||
|
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||||
|
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restore mesh llama build cache
|
||||||
|
id: llama_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||||
|
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||||
|
|
||||||
|
- name: Build mesh llama native libraries
|
||||||
|
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||||
|
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$MESH_REV_SHORT" -type d -name "$MESH_REV_SHORT" | head -1)
|
||||||
|
if [[ -z "$MESH_ROOT" ]]; then
|
||||||
|
echo "::error::mesh-llm checkout for $MESH_REV_SHORT not found after cargo fetch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export LLAMA_STAGE_BACKEND=metal
|
||||||
|
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||||
|
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||||
|
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||||
|
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||||
|
|
||||||
|
- name: Save mesh llama build cache
|
||||||
|
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||||
|
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||||
|
|
||||||
|
- name: Build unsigned Tauri app
|
||||||
|
run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.canary.conf.json
|
||||||
|
env:
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
LLAMA_STAGE_BACKEND: metal
|
||||||
|
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||||
|
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||||
|
|
||||||
|
- name: Locate unsigned DMG
|
||||||
|
id: unsigned
|
||||||
|
run: |
|
||||||
|
DMG=$(find desktop/src-tauri/target/release/bundle/dmg -name '*.dmg' -type f | head -1)
|
||||||
|
if [[ -z "$DMG" ]]; then
|
||||||
|
echo "::error::No DMG found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set DMG Finder label text size
|
||||||
|
env:
|
||||||
|
DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
run: desktop/scripts/set-dmg-finder-text-size.sh "$DMG_PATH" 14
|
||||||
|
|
||||||
|
# mdx-ios-codesign-helper discovers this file by its exact lowercase basename.
|
||||||
|
- name: Stage signing entitlements
|
||||||
|
run: cp desktop/src-tauri/Entitlements.plist "${RUNNER_TEMP}/entitlements.plist"
|
||||||
|
|
||||||
|
- name: Codesign and notarize
|
||||||
|
id: codesign
|
||||||
|
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
|
||||||
|
with:
|
||||||
|
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
|
||||||
|
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
|
||||||
|
unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }}
|
||||||
|
entitlements-plist-path: ${{ runner.temp }}/entitlements.plist
|
||||||
|
artifact-name: buzz-canary-${{ github.sha }}-${{ github.run_id }}-arm64
|
||||||
|
|
||||||
|
- name: Verify signed app
|
||||||
|
env:
|
||||||
|
SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract"
|
||||||
|
rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR"
|
||||||
|
ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$EXTRACT_DIR/Buzz.app"
|
||||||
|
spctl --assess --type execute --verbose=4 "$EXTRACT_DIR/Buzz.app"
|
||||||
|
desktop/scripts/verify-macos-entitlements.sh "$EXTRACT_DIR/Buzz.app"
|
||||||
|
|
||||||
|
- name: Stage signed DMG
|
||||||
|
id: artifact
|
||||||
|
env:
|
||||||
|
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
NAME="Buzz_${VERSION}_aarch64-signed.dmg"
|
||||||
|
cp "$SIGNED_DMG" "$RUNNER_TEMP/$NAME"
|
||||||
|
echo "path=$RUNNER_TEMP/$NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload signed canary
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: buzz-macos-canary-${{ github.sha }}
|
||||||
|
path: ${{ steps.artifact.outputs.path }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Measure release Cargo cache inputs
|
||||||
|
if: always()
|
||||||
|
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
|
||||||
|
|
||||||
|
# Only this trusted, main-bound canary writes the cache. Excluding bundle
|
||||||
|
# output prevents installers or signed artifacts from entering it.
|
||||||
|
- name: Save exact release Cargo cache
|
||||||
|
if: steps.rust_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Save pnpm store cache
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
name: Sprig image
|
||||||
|
|
||||||
|
# Builds and publishes the public agent container image as
|
||||||
|
# ghcr.io/block/buzz-sprig — the digest-pinned box the Kubernetes backend
|
||||||
|
# deploys agents into (see Dockerfile.sprig and docs/remote-agents.md).
|
||||||
|
#
|
||||||
|
# Strategy mirrors docker.yml (the relay image): each architecture builds on
|
||||||
|
# its native runner, pushes to GHCR by digest, then a merge job stitches the
|
||||||
|
# per-arch digests into one multi-arch manifest and attests provenance.
|
||||||
|
# No QEMU emulation.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push to main (paths-filtered) → :main + :sha-<7>
|
||||||
|
# - tag sprig-v* → semver family (shared with sprig.yml's
|
||||||
|
# binary release — one tag versions both)
|
||||||
|
# - pull_request (paths-filtered) → build only, no push
|
||||||
|
# - workflow_dispatch → manual publish at the current ref
|
||||||
|
#
|
||||||
|
# NOTE: the first push creates the GHCR package PRIVATE by default. An org
|
||||||
|
# admin must flip ghcr.io/block/buzz-sprig to public once (Package settings →
|
||||||
|
# Change visibility). Subsequent pushes keep the visibility.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["sprig-v[0-9]*"]
|
||||||
|
paths:
|
||||||
|
- "Dockerfile.sprig"
|
||||||
|
- "scripts/sprig-entrypoint.sh"
|
||||||
|
- ".github/workflows/sprig-image.yml"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "rust-toolchain.toml"
|
||||||
|
- "crates/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Dockerfile.sprig"
|
||||||
|
- "scripts/sprig-entrypoint.sh"
|
||||||
|
- ".github/workflows/sprig-image.yml"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sprig-image-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Single source of truth for the image name; override with the
|
||||||
|
# GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml).
|
||||||
|
IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || 'ghcr.io/block/buzz-sprig' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.platform }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
with:
|
||||||
|
# Same OOM cap as docker.yml — Rust compiles blow the 7GB runner
|
||||||
|
# at buildkit's default parallelism of 4.
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[worker.oci]
|
||||||
|
max-parallelism = 2
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
# match=^sprig-v(.*)$ strips the tag prefix for the semver parser,
|
||||||
|
# exactly as docker.yml does for relay-v. :latest comes from
|
||||||
|
# flavor.latest=auto — stable semver only, never main pushes.
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=semver,pattern={{version}},match=^sprig-v(.*)$
|
||||||
|
type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Buzz Sprig
|
||||||
|
org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl)
|
||||||
|
org.opencontainers.image.licenses=Apache-2.0
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.sprig
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
|
||||||
|
cache-to: |
|
||||||
|
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
env:
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
touch "/tmp/digests/${DIGEST#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: sprig-digest-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Merge multi-arch manifest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: build
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download per-arch digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: sprig-digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
# Must mirror the build job's tag matrix exactly (see docker.yml).
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=semver,pattern={{version}},match=^sprig-v(.*)$
|
||||||
|
type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$
|
||||||
|
|
||||||
|
- name: Create and push manifest list
|
||||||
|
id: manifest
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
|
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tags=()
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
[ -n "$tag" ] && tags+=("-t" "$tag")
|
||||||
|
done <<< "$META_TAGS"
|
||||||
|
|
||||||
|
digests=()
|
||||||
|
for digest in *; do
|
||||||
|
digests+=("${IMAGE_NAME}@sha256:${digest}")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
|
||||||
|
|
||||||
|
first_tag=$(echo "$META_TAGS" | head -n1)
|
||||||
|
merged_digest=$(docker buildx imagetools inspect "$first_tag" \
|
||||||
|
--format '{{json .Manifest}}' | jq -r '.digest')
|
||||||
|
echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Attest provenance for the merged image
|
||||||
|
# Verify with: gh attestation verify oci://ghcr.io/block/buzz-sprig:<tag> --owner block
|
||||||
|
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||||
|
with:
|
||||||
|
subject-name: ${{ env.IMAGE_NAME }}
|
||||||
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
|
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "### Sprig image published"
|
||||||
|
echo '```'
|
||||||
|
echo "${IMAGE_NAME}@${DIGEST}"
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
name: Sprig
|
||||||
|
|
||||||
|
# Builds and publishes Sprig — one deploy-anywhere Linux multicall binary for:
|
||||||
|
# buzz-acp ACP harness that bridges Buzz events to the LLM agent
|
||||||
|
# buzz-agent ACP-compliant agent (spawns MCP, calls LLMs)
|
||||||
|
# buzz-dev-mcp Developer MCP server (multicall: rg, tree, buzz,
|
||||||
|
# git-credential-nostr, git-sign-nostr)
|
||||||
|
#
|
||||||
|
# Targets: x86_64-unknown-linux-musl and aarch64-unknown-linux-musl (static
|
||||||
|
# musl so the tarball runs on any modern Linux without libc surprises).
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push to main → updates rolling `sprig-latest` release
|
||||||
|
# - tag `sprig-v*` → versioned release
|
||||||
|
# - workflow_dispatch → manual canary build (no release publish unless asked)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["sprig-v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: "Publish to the rolling release (otherwise artifacts only)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target:
|
||||||
|
- x86_64-unknown-linux-musl
|
||||||
|
- aarch64-unknown-linux-musl
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||||
|
|
||||||
|
- name: Install cross
|
||||||
|
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||||
|
with:
|
||||||
|
tool: cross@0.2.5
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \
|
||||||
|
| jq -r '.packages[] | select(.name=="sprig") | .version')
|
||||||
|
|
||||||
|
REF="${GITHUB_REF#refs/tags/}"
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/sprig-v* ]]; then
|
||||||
|
VERSION="${REF#sprig-v}"
|
||||||
|
CHANNEL="tag"
|
||||||
|
else
|
||||||
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
|
VERSION="${WORKSPACE_VERSION}+git.${SHORT_SHA}"
|
||||||
|
CHANNEL="rolling"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "workspace_version=$WORKSPACE_VERSION"
|
||||||
|
echo "version=$VERSION"
|
||||||
|
echo "channel=$CHANNEL"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved version=$VERSION channel=$CHANNEL"
|
||||||
|
|
||||||
|
- name: Build & package Sprig
|
||||||
|
id: pkg
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||||
|
GIT_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$CHANNEL" == "tag" ]]; then
|
||||||
|
ARCHIVE_BASENAME="sprig-${VERSION}-${TARGET}"
|
||||||
|
else
|
||||||
|
ARCHIVE_BASENAME="sprig-${TARGET}"
|
||||||
|
fi
|
||||||
|
ARCHIVE_BASENAME="$ARCHIVE_BASENAME" \
|
||||||
|
./scripts/build-sprig.sh "$VERSION" "$TARGET"
|
||||||
|
ARCHIVE="dist/${ARCHIVE_BASENAME}.tar.gz"
|
||||||
|
test -f "$ARCHIVE"
|
||||||
|
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload workflow artifact
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: sprig-${{ matrix.target }}
|
||||||
|
path: |
|
||||||
|
${{ steps.pkg.outputs.archive }}
|
||||||
|
${{ steps.pkg.outputs.archive }}.sha256
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish rolling release
|
||||||
|
needs: build
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|| (github.event_name == 'workflow_dispatch' && inputs.publish)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: Download all Sprig artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: sprig-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: List assets
|
||||||
|
run: ls -lh dist/
|
||||||
|
|
||||||
|
- name: Update rolling release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="sprig-latest"
|
||||||
|
TITLE="Sprig (rolling)"
|
||||||
|
NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)."
|
||||||
|
|
||||||
|
gh release edit "$TAG" \
|
||||||
|
--prerelease \
|
||||||
|
--title "$TITLE" \
|
||||||
|
--notes "$NOTES"
|
||||||
|
gh release upload "$TAG" dist/* --clobber
|
||||||
|
|
||||||
|
publish-tag:
|
||||||
|
name: Publish tagged release
|
||||||
|
needs: build
|
||||||
|
if: startsWith(github.ref, 'refs/tags/sprig-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
|
- name: Download all Sprig artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: sprig-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Resolve tag version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
REF="${GITHUB_REF#refs/tags/}"
|
||||||
|
VERSION="${REF#sprig-v}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$REF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create tagged release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--title "Sprig v${VERSION}" \
|
||||||
|
--notes "Sprig v${VERSION} — Linux all-in-one builds of buzz-acp + buzz-agent + buzz-dev-mcp." \
|
||||||
|
dist/*
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
name: Windows Canary
|
||||||
|
|
||||||
|
# Produces an unsigned Windows NSIS installer from main without creating
|
||||||
|
# a tag, GitHub Release, or auto-updater artifact. The installer is available
|
||||||
|
# only as a short-lived GitHub Actions artifact for explicit testing.
|
||||||
|
#
|
||||||
|
# Design notes vs. signed-macos-canary.yml:
|
||||||
|
# - No mesh-llm: release-windows doesn't build it.
|
||||||
|
# - pnpm store restore/save pattern mirrors ci.yml:149-196.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows canary
|
||||||
|
if: github.repository == 'block/buzz'
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
TARGET: x86_64-pc-windows-msvc
|
||||||
|
steps:
|
||||||
|
- name: Require main
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SOURCE_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then
|
||||||
|
echo "::error::Canary builds must run from main; got $SOURCE_REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# The Windows runner ships with rustup preinstalled; rust-toolchain.toml
|
||||||
|
# in the repo root pins the channel (1.95.0) automatically. We only need
|
||||||
|
# to ensure the cross-compile target is registered; on windows-latest the
|
||||||
|
# host IS x86_64-pc-windows-msvc so this is typically a no-op.
|
||||||
|
- name: Add Rust target
|
||||||
|
shell: bash
|
||||||
|
run: rustup target add "$TARGET"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 24.14.1
|
||||||
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
with:
|
||||||
|
version: 11.4.0
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restore pnpm store cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
shell: bash
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Derive canary version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
|
||||||
|
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||||
|
echo "::error::Desktop version '$BASE_VERSION' is not semver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-test.${GITHUB_RUN_NUMBER}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building canary version $VERSION from $GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Patch canary version
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
|
||||||
|
cd src-tauri && cargo update --workspace
|
||||||
|
|
||||||
|
- name: Resolve native toolchain identity
|
||||||
|
id: native_toolchain
|
||||||
|
shell: bash
|
||||||
|
run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Compute this after cargo update so the key describes the graph that is
|
||||||
|
# actually compiled. The helper normalizes only Buzz Desktop's release
|
||||||
|
# version, allowing a canary to warm an otherwise identical tag build.
|
||||||
|
- name: Compute exact release cache key
|
||||||
|
id: rust_cache_key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}
|
||||||
|
run: |
|
||||||
|
KEY=$(scripts/desktop-release-cache-key.py \
|
||||||
|
--platform "$RUNNER_OS" \
|
||||||
|
--target x86_64-pc-windows-msvc \
|
||||||
|
--features default \
|
||||||
|
--native-inputs "$NATIVE_TOOLCHAIN_ID")
|
||||||
|
echo "key=$KEY" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release cache key: $KEY"
|
||||||
|
|
||||||
|
- name: Restore exact release Cargo cache
|
||||||
|
id: rust_cache
|
||||||
|
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Generate non-updating bundle config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"createUpdaterArtifacts": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
- name: Build sidecars
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
|
||||||
|
./scripts/bundle-sidecars.sh "$TARGET"
|
||||||
|
|
||||||
|
- name: Build Windows NSIS installer (unsigned)
|
||||||
|
shell: bash
|
||||||
|
run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json
|
||||||
|
env:
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||||
|
|
||||||
|
- name: Locate NSIS installer
|
||||||
|
id: artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
|
||||||
|
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
|
||||||
|
if [[ -z "$EXE" ]]; then
|
||||||
|
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "exe=$EXE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload Windows canary installer
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: buzz-windows-canary-${{ github.sha }}
|
||||||
|
path: ${{ steps.artifact.outputs.exe }}
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Measure release Cargo cache inputs
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true
|
||||||
|
|
||||||
|
# Only this trusted, main-bound canary writes the cache. Excluding bundle
|
||||||
|
# output prevents installers from entering it.
|
||||||
|
- name: Save exact release Cargo cache
|
||||||
|
if: steps.rust_cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
desktop/src-tauri/target
|
||||||
|
!desktop/src-tauri/target/**/release/bundle
|
||||||
|
key: ${{ steps.rust_cache_key.outputs.key }}
|
||||||
|
|
||||||
|
- name: Save pnpm store cache
|
||||||
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
|
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target/
|
||||||
|
/dist/
|
||||||
|
/admin-web/dist/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# lefthook-generated hook scripts (machine-specific)
|
||||||
|
.hooks/
|
||||||
|
|
||||||
|
# Environment files (may contain secrets)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.*.sw?
|
||||||
|
|
||||||
|
# OS artifacts
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Scratch / working files (AI reviews, notes, drafts)
|
||||||
|
.scratch/
|
||||||
|
|
||||||
|
# Playwright artifacts
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
blob-report/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Node modules (pnpm workspace hoists to root)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
|
||||||
|
/package-lock.json
|
||||||
|
|
||||||
|
# sqlx offline query data (generated, not portable)
|
||||||
|
.sqlx/
|
||||||
|
|
||||||
|
# SQLite database (created by relay in CWD)
|
||||||
|
buzz.db
|
||||||
|
buzz.db-wal
|
||||||
|
buzz.db-shm
|
||||||
|
|
||||||
|
# Docker volumes (if mounted locally)
|
||||||
|
mysql-data/
|
||||||
|
typesense-data/
|
||||||
|
|
||||||
|
# Hermit (toolchain manager cache)
|
||||||
|
.hermit/
|
||||||
|
doc/
|
||||||
|
/repos/
|
||||||
|
|
||||||
|
# Local identity files
|
||||||
|
identity.key
|
||||||
|
**/identity.key
|
||||||
|
|
||||||
|
# Claude Code worktrees
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# mesh-llm build cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
|
||||||
|
deploy/charts/*/charts/*.tgz
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Cargo registry checksums are integrity hashes generated by Cargo, not payment data.
|
||||||
|
exclude_rules_for_files:
|
||||||
|
sq.pii.cc.visa:
|
||||||
|
- Cargo.lock
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"schema": 2,
|
||||||
|
"version": "0.5.8",
|
||||||
|
"base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
|
||||||
|
"previous_tag": "desktop-v0.5.7",
|
||||||
|
"previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954",
|
||||||
|
"previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3",
|
||||||
|
"tag": "desktop-v0.5.8",
|
||||||
|
"commit_count": 4
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"rust-analyzer.cargo.targetDir": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
# AGENTS.md — AI Agent Contributor Guide
|
||||||
|
|
||||||
|
This guide is for AI agents contributing to the Buzz codebase. It covers
|
||||||
|
agent-specific context and conventions. For general contributor info (setup,
|
||||||
|
code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
|
||||||
|
|
||||||
|
| Repo | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
|
||||||
|
| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |
|
||||||
|
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
|
||||||
|
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
|
||||||
|
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
|
||||||
|
|
||||||
|
```
|
||||||
|
block/buzz (source)
|
||||||
|
├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
|
||||||
|
├─► sprout-oss (relay Docker image → ECR)
|
||||||
|
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
|
||||||
|
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [RELEASING.md](RELEASING.md) for the desktop release flow and
|
||||||
|
[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor
|
||||||
|
access information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
# Relay + core
|
||||||
|
buzz-relay # WebSocket relay server — main entry point; also hosts git + huddle audio
|
||||||
|
buzz-core # Core types, event verification, filter matching, kind registry
|
||||||
|
buzz-db # Postgres event store and data access layer
|
||||||
|
buzz-auth # Authentication and authorization
|
||||||
|
buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators
|
||||||
|
buzz-search # Postgres FTS full-text search
|
||||||
|
buzz-audit # Hash-chain audit log
|
||||||
|
buzz-media # Blossom/S3 media storage
|
||||||
|
# Agent surface
|
||||||
|
buzz-acp # ACP harness bridging Buzz events to AI agents
|
||||||
|
buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
|
||||||
|
buzz-dev-mcp # Developer MCP server — shell + file-edit tools
|
||||||
|
buzz-persona # Agent persona packs
|
||||||
|
buzz-workflow # YAML-as-code workflow engine (evalexpr conditions)
|
||||||
|
# Clients + interop
|
||||||
|
buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing
|
||||||
|
buzz-pairing-cli # CLI for NIP-AB device pairing interop testing
|
||||||
|
git-sign-nostr # Sign git objects with a Nostr key
|
||||||
|
git-credential-nostr # Git credential helper for Nostr-authed push/fetch
|
||||||
|
# Tooling + shared
|
||||||
|
buzz-cli # Agent-first CLI
|
||||||
|
buzz-sdk # Typed Nostr event builders
|
||||||
|
buzz-admin # Operator CLI for relay administration
|
||||||
|
buzz-ws-client # Shared NIP-42 WebSocket client (connect, auth, publish)
|
||||||
|
buzz-test-client # Integration test client and E2E test suite
|
||||||
|
sprig # All-in-one harness bundling ACP, agent, and dev MCP
|
||||||
|
|
||||||
|
desktop/ # Tauri 2 + React 19 desktop app
|
||||||
|
web/ # Browser web client (repo browser, served by the relay)
|
||||||
|
mobile/ # Flutter mobile app
|
||||||
|
migrations/ # SQL migrations (auto-applied on relay startup)
|
||||||
|
scripts/ # Dev tooling
|
||||||
|
.env.example # Config template — copy to .env before running
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. ./bin/activate-hermit # activate hermit toolchain (Rust, Node, etc.)
|
||||||
|
cp .env.example .env # configure local environment
|
||||||
|
just setup # install deps, run migrations
|
||||||
|
just relay # start relay at ws://localhost:3000
|
||||||
|
just ci # run before any PR
|
||||||
|
```
|
||||||
|
|
||||||
|
See CONTRIBUTING.md for full setup details and dependency requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Gates
|
||||||
|
|
||||||
|
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
|
||||||
|
unit tests + builds. Clippy passing does not mean fmt passes; run both.
|
||||||
|
|
||||||
|
Run `just test` for integration tests if you touched `buzz-relay`,
|
||||||
|
`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.
|
||||||
|
|
||||||
|
**Pre-commit hooks** are installed automatically by `just setup` and auto-fix
|
||||||
|
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
|
||||||
|
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
|
||||||
|
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
|
||||||
|
commit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript
|
||||||
|
typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop
|
||||||
|
JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are
|
||||||
|
CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run
|
||||||
|
`just ci` for the full local gate. Run `just hooks` to
|
||||||
|
re-install hooks after env changes. Before agents run Git or hooks, activate the
|
||||||
|
repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook
|
||||||
|
commands to compensate for an unconfigured shell `PATH`.
|
||||||
|
|
||||||
|
**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push.
|
||||||
|
|
||||||
|
Additional rules:
|
||||||
|
- No `unsafe` code
|
||||||
|
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
|
||||||
|
- New public API must have doc comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.
|
||||||
|
|
||||||
|
**Prefer Nostr events over new HTTP endpoints**: For new feature work, model
|
||||||
|
the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler
|
||||||
|
in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is
|
||||||
|
reserved for things that genuinely need an HTTP-only surface: media upload/download
|
||||||
|
(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,
|
||||||
|
and the generic Nostr bridge endpoints:
|
||||||
|
|
||||||
|
- `POST /events` — submit any signed event (same path the WebSocket uses).
|
||||||
|
- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters
|
||||||
|
are routed to `buzz-search` (Postgres FTS) automatically.
|
||||||
|
- `POST /count` — Nostr COUNT filters over HTTP.
|
||||||
|
|
||||||
|
If you find yourself reaching for a new HTTP endpoint, first check whether
|
||||||
|
an event kind would do the job — it usually will, and you get realtime
|
||||||
|
fan-out, NIP-29 scoping, and the existing auth pipeline for free.
|
||||||
|
|
||||||
|
Reference https://github.com/nostr-protocol/nips
|
||||||
|
|
||||||
|
**Event kinds**: All event kind integers are defined in
|
||||||
|
`buzz-core/src/kind.rs`. New features get new kind integers — add them here
|
||||||
|
first, then implement handling in the relay.
|
||||||
|
|
||||||
|
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
|
||||||
|
Filters and queries must scope to `h` tags when operating within a channel.
|
||||||
|
This applies to events *inside* a channel. Addressable events that describe a
|
||||||
|
channel carry its id in their `d` tag instead: kind:39000 (metadata),
|
||||||
|
kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels
|
||||||
|
from the `d` tag of their kind:39002 events, not from `h`.
|
||||||
|
|
||||||
|
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
|
||||||
|
|
||||||
|
**Workflow conditions**: `buzz-workflow` uses
|
||||||
|
[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
|
||||||
|
simple and testable.
|
||||||
|
|
||||||
|
**Thread counters**: `reply_count` and `descendant_count` are materialized on
|
||||||
|
thread root events. Any code that inserts replies must update these counters —
|
||||||
|
check existing reply handlers for the pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent CLI (`buzz-cli`)
|
||||||
|
|
||||||
|
`buzz` is the agent-first CLI. Auth env vars
|
||||||
|
(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected
|
||||||
|
by the ACP harness into managed agent subprocesses. In development, set
|
||||||
|
`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.
|
||||||
|
|
||||||
|
### Building the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release -p buzz-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary location: `./target/release/buzz`. Add `./target/release` to `PATH`
|
||||||
|
or invoke with the full path.
|
||||||
|
|
||||||
|
### Deep Links
|
||||||
|
|
||||||
|
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
|
||||||
|
thread. To read the linked thread:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
buzz messages thread --channel <uuid> --event <hex> --format compact
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract `channel` and `id` from the URL query parameters. The optional
|
||||||
|
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
|
||||||
|
the full thread from the event ID alone.
|
||||||
|
|
||||||
|
All reads return sig-stripped JSON arrays; all writes return
|
||||||
|
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
|
||||||
|
0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).
|
||||||
|
|
||||||
|
`--format compact` is a **global** flag — it goes before the subcommand:
|
||||||
|
`buzz --format compact channels list`, NOT `buzz channels list --format compact`.
|
||||||
|
|
||||||
|
See `crates/buzz-cli/TESTING.md` for the full live-testing runbook.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test-unit # unit tests, no infrastructure needed
|
||||||
|
just test # full integration suite (requires Postgres + Redis)
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E tests live in `crates/buzz-test-client/tests/`:
|
||||||
|
- `e2e_relay.rs` — WebSocket relay protocol
|
||||||
|
- `e2e_media.rs` — media upload/download (Blossom)
|
||||||
|
- `e2e_media_extended.rs` — extended media scenarios
|
||||||
|
- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)
|
||||||
|
|
||||||
|
Desktop E2E: `cd desktop && pnpm exec playwright test`
|
||||||
|
|
||||||
|
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
||||||
|
|
||||||
|
### PR Screenshots
|
||||||
|
|
||||||
|
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
|
||||||
|
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
|
||||||
|
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
|
||||||
|
> from a PR body/comment. If you hand-edit PR markdown, run
|
||||||
|
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
|
||||||
|
|
||||||
|
For mobile simulator screenshots, save the PNGs in a local directory and run
|
||||||
|
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
|
||||||
|
with a markdown template containing `{{filename}}` placeholders.
|
||||||
|
|
||||||
|
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
|
||||||
|
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
|
||||||
|
starts preview server, runs Playwright automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just desktop-screenshot --name home
|
||||||
|
just desktop-screenshot --name channel --route /channels/general
|
||||||
|
just desktop-screenshot --name search --click open-search
|
||||||
|
just desktop-screenshot --name settings --click open-settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--name` (filename), `--route` (client route), `--active-channel`
|
||||||
|
(channel to view), `--click` (left-click data-testid or CSS selector),
|
||||||
|
`--right-click` (right-click for context menus), `--hover` (hover before
|
||||||
|
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
|
||||||
|
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
|
||||||
|
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
|
||||||
|
Output is a PNG path on stdout.
|
||||||
|
|
||||||
|
Use `--messages` to inject content into a channel before capture. The JSON file
|
||||||
|
is an array of objects — `channelName` and `content` are required, all other
|
||||||
|
fields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"channelName": "random",
|
||||||
|
"content": "Hey @tyler check this out",
|
||||||
|
"pubkey": "953d...",
|
||||||
|
"kind": 40002,
|
||||||
|
"mentionPubkeys": ["deadbeef..."],
|
||||||
|
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
|
||||||
|
"parentEventId": "abc123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `--active-channel`, all messages must target the same channel and the
|
||||||
|
helper navigates to that channel (useful for showing message content). With
|
||||||
|
`--active-channel`, messages can target multiple channels while the "camera"
|
||||||
|
stays on the specified channel (useful for unread indicators, badges, etc.).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Messages in the channel you're viewing (code blocks, formatting, etc.)
|
||||||
|
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
|
||||||
|
|
||||||
|
# Messages in OTHER channels to trigger unread state
|
||||||
|
just desktop-screenshot --name unread-dot \
|
||||||
|
--active-channel general --messages /tmp/badge-msgs.json
|
||||||
|
|
||||||
|
# Cropped to sidebar only (256px wide)
|
||||||
|
just desktop-screenshot --name sidebar-unread \
|
||||||
|
--active-channel general --messages /tmp/badge-msgs.json \
|
||||||
|
--clip 0,0,256,720
|
||||||
|
|
||||||
|
# Context menu on an unread channel (wider crop to include popup)
|
||||||
|
just desktop-screenshot --name ctx-mark-read \
|
||||||
|
--active-channel general --messages /tmp/badge-msgs.json \
|
||||||
|
--right-click channel-random --clip 0,200,320,300
|
||||||
|
|
||||||
|
# Hover state (e.g. copy button reveal)
|
||||||
|
just desktop-screenshot --name copy-hover \
|
||||||
|
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
|
||||||
|
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
|
||||||
|
|
||||||
|
`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch
|
||||||
|
(`agent-screenshots/<github-username>`) and posts a PR comment with
|
||||||
|
commit-SHA-based image URLs (immutable — safe from later overwrites):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/post-screenshots.sh 803 test-results/screenshots
|
||||||
|
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
|
||||||
|
```
|
||||||
|
|
||||||
|
The body file supports `{{filename}}` placeholders (without `.png`) to inline
|
||||||
|
images at specific positions. Images not referenced by any placeholder are
|
||||||
|
appended at the end. Without placeholders, all images are appended (backward
|
||||||
|
compatible).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Unread dot
|
||||||
|
A message arrives in `#random`.
|
||||||
|
|
||||||
|
{{01-unread-dot}}
|
||||||
|
|
||||||
|
### Context menu
|
||||||
|
Right-click shows "Mark as read".
|
||||||
|
|
||||||
|
{{02-context-menu}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
|
||||||
|
branch, but the script **appends a new PR comment** — it does not edit or
|
||||||
|
delete the previous one. After reposting, delete the superseded comment so
|
||||||
|
only the current set remains, otherwise reviewers still see the stale images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List screenshot comments to find the stale one's id
|
||||||
|
gh pr view <pr> --repo block/buzz --json comments \
|
||||||
|
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
|
||||||
|
gh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
|
||||||
|
|
||||||
|
### Writing E2E Screenshot Specs
|
||||||
|
|
||||||
|
When screenshots need seeded state, live messages, or UI interaction before
|
||||||
|
capture, write a Playwright spec instead of using `just desktop-screenshot`.
|
||||||
|
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
|
||||||
|
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
|
||||||
|
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
|
||||||
|
|
||||||
|
**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri
|
||||||
|
bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured`
|
||||||
|
in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so
|
||||||
|
`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails
|
||||||
|
with `Cannot read properties of undefined (reading 'invoke')` — the app renders
|
||||||
|
"Community connection failed" instead of the UI under test. That looks exactly
|
||||||
|
like a product bug rather than a build mistake, so it burns real time.
|
||||||
|
`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for
|
||||||
|
you; prefer them over a manual build plus `playwright test`.
|
||||||
|
|
||||||
|
**Stale server:** `reuseExistingServer: true` means a previous build's server
|
||||||
|
serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running
|
||||||
|
tests after code changes.
|
||||||
|
|
||||||
|
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
|
||||||
|
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
|
||||||
|
bridge triggers mount.
|
||||||
|
|
||||||
|
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
|
||||||
|
`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
|
||||||
|
subscription. Navigate to the channel first (triggers subscription), then away
|
||||||
|
(so unread indicators appear), then inject.
|
||||||
|
|
||||||
|
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
|
||||||
|
resolves mid-animation — wait for completion before screenshotting. Use the
|
||||||
|
shared helper (mandatory before any `page.screenshot()` or
|
||||||
|
`locator.screenshot()` in specs):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { waitForAnimations } from "../helpers/animations";
|
||||||
|
|
||||||
|
// ... after the element is visible but before capturing:
|
||||||
|
await waitForAnimations(page);
|
||||||
|
await page.screenshot({ path: "...", clip: { ... } });
|
||||||
|
```
|
||||||
|
|
||||||
|
The `just desktop-screenshot` path (`screenshot.mjs`) calls
|
||||||
|
`waitForAnimations` automatically — no manual step needed there.
|
||||||
|
|
||||||
|
For per-element waits (rare — prefer the page-level helper above):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await menuItem.evaluate((el) =>
|
||||||
|
Promise.all(
|
||||||
|
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
|
||||||
|
for sidebar features. Sidebar = 256px; context menus ~450px.
|
||||||
|
|
||||||
|
**Distinct states — verify before posting:** when one view renders many
|
||||||
|
elements at once (e.g. all team cards in a single grid), an unscoped
|
||||||
|
full-page `page.screenshot()` captures the *same* pixels for every shot, so
|
||||||
|
multiple PNGs come out byte-identical. Scope each shot to its subject with
|
||||||
|
`locator.screenshot()` (full-page `clip` only when an overlay like an open
|
||||||
|
dropdown must be included). Then gate on hash distinctness before posting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
|
||||||
|
```
|
||||||
|
|
||||||
|
Identical hashes mean two shots captured the same state — fix the spec, do
|
||||||
|
not post. This catches the most common screenshot regression.
|
||||||
|
|
||||||
|
**`general` has pre-seeded messages** making `hasUnread` always true. Use
|
||||||
|
`engineering` for "muted + no unread" visual states.
|
||||||
|
|
||||||
|
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
|
||||||
|
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
|
||||||
|
description. See [PR #803](https://github.com/block/buzz/pull/803).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
|
||||||
|
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
|
||||||
|
3. **`messages search` must include `--kinds`** — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least `--kinds 9,45001,45003` to scope the query.
|
||||||
|
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
|
||||||
|
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
|
||||||
|
6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
|
||||||
|
7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App
|
||||||
|
|
||||||
|
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
|
||||||
|
organized under `desktop/src/features/`. Biome handles linting and formatting.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just desktop-dev # web-only dev server (faster iteration)
|
||||||
|
just dev # full Tauri app with native shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text sizing & zoom (use rem, never px)
|
||||||
|
|
||||||
|
The desktop app implements Cmd +/- zoom by scaling the root `<html>`
|
||||||
|
font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native
|
||||||
|
webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes
|
||||||
|
are frozen.**
|
||||||
|
|
||||||
|
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
|
||||||
|
px:
|
||||||
|
|
||||||
|
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author
|
||||||
|
text === `text-base` (16px) — chat is the app's base type size**, and the
|
||||||
|
surrounding timeline elements (timestamps, system rows, code, reactions) are
|
||||||
|
deliberate steps on that same stock ramp.
|
||||||
|
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text
|
||||||
|
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
|
||||||
|
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
|
||||||
|
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
|
||||||
|
apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary
|
||||||
|
values.
|
||||||
|
- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom
|
||||||
|
and caused the message-timeline regression (PR #891).
|
||||||
|
- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They
|
||||||
|
zoom fine but re-fragment the scale we consolidated. Use a named token.
|
||||||
|
|
||||||
|
Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
|
||||||
|
needs a size the stock/`2xs`/`3xs` scale can't express should you **add a
|
||||||
|
rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)
|
||||||
|
rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in
|
||||||
|
`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any
|
||||||
|
new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs
|
||||||
|
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
|
||||||
|
script.
|
||||||
|
|
||||||
|
### Community Switching
|
||||||
|
|
||||||
|
The desktop app supports multiple communities (each backed by a different relay).
|
||||||
|
Switching communities does **not** reload the page — it uses React key-based
|
||||||
|
remounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire
|
||||||
|
community-scoped subtree to unmount and remount with fresh state.
|
||||||
|
|
||||||
|
**Module-level singletons must be explicitly reset.** React remounting only
|
||||||
|
clears React state (useState, useRef, context). Module-level variables (Maps,
|
||||||
|
class instances, cached promises) survive across remounts. Every community-scoped
|
||||||
|
singleton needs a reset function wired into `resetCommunityState()` in
|
||||||
|
`desktop/src/features/communities/useCommunityInit.ts`.
|
||||||
|
|
||||||
|
Current singletons that are reset on relay boundary changes (same-relay
|
||||||
|
reconnects preserve pending avatar verification work):
|
||||||
|
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
|
||||||
|
- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
|
||||||
|
- `clearAllDrafts()` — message draft cache
|
||||||
|
- `resetAgentObserverStore()` — agent observer relay store
|
||||||
|
- `resetActiveAgentTurnsStore()` — active agent turn timers
|
||||||
|
- `resetAgentWorkingSignal()` — agent working indicator signal
|
||||||
|
- `resetAvatarProfileSync()` — pending verified-avatar profile writes
|
||||||
|
- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
|
||||||
|
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
|
||||||
|
- `resetMediaCaches()` — proxy port and relay origin caches
|
||||||
|
- `resetVideoPlayerState()` — video player singleton
|
||||||
|
- `resetRenderScopedReactionHydration()` — reaction hydration cache
|
||||||
|
- `clearSearchHitEventCache()` — search result event cache
|
||||||
|
- `clearMarkdownNodeCache()` — markdown parse-node cache
|
||||||
|
- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)
|
||||||
|
|
||||||
|
**If you add a new module-level cache, Map, or class instance that holds
|
||||||
|
community-scoped data, you must add its reset to `resetCommunityState()`.**
|
||||||
|
Failure to do so causes data from the old community to leak into the new one.
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
|
||||||
|
- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend
|
||||||
|
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile App (Flutter)
|
||||||
|
|
||||||
|
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
|
||||||
|
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
|
||||||
|
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
|
||||||
|
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
|
||||||
|
stay in sync with `desktop/src/shared/constants/kinds.ts`
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
|
||||||
|
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
|
||||||
|
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
|
||||||
|
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
|
||||||
|
`dart format` are safe for agents to run.
|
||||||
|
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
|
||||||
|
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
|
||||||
|
over raw `Theme.of(context)` calls.
|
||||||
|
- **Keep widgets small and composable.** One public widget per file; push
|
||||||
|
private sub-widgets (`_Foo`) into sibling `part` files under a
|
||||||
|
`<page>/` folder rather than growing the page file. Hard ceiling:
|
||||||
|
**1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via
|
||||||
|
`just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
|
||||||
|
If the guard trips, **split the file — never bump the limit or add an
|
||||||
|
override to slip under it.**
|
||||||
|
- Feature modules must not import from other feature modules — only from
|
||||||
|
`shared/`.
|
||||||
|
- Use `Grid` tokens for spacing, `Radii` for border radius.
|
||||||
|
|
||||||
|
### Quality Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
dart format --output=none --set-exit-if-changed .
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
|
||||||
|
|
||||||
|
To run the app locally (starts Docker, relay, iOS simulator automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just mobile-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
When run from a git worktree, `just mobile-dev` (and `just
|
||||||
|
mobile-build-android`) give the debug build a per-worktree app identifier
|
||||||
|
(keyed to the worktree directory name) and a branch-labelled app name via
|
||||||
|
`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees
|
||||||
|
install side by side. Release builds are unaffected. `just mobile-clean`
|
||||||
|
removes stale worktree-suffixed installs from simulators/emulators. See
|
||||||
|
[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio
|
||||||
|
usage.
|
||||||
|
|
||||||
|
### Testing Conventions
|
||||||
|
|
||||||
|
- Prefer **widget tests** over unit tests for UI components — test the
|
||||||
|
whole widget tree, not individual methods.
|
||||||
|
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
|
||||||
|
- Fake notifiers should extend the real notifier class and override `build()`.
|
||||||
|
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
|
||||||
|
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
|
||||||
|
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
|
||||||
|
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
|
||||||
|
- [README.md](README.md) — project overview and quick start
|
||||||
+827
@@ -0,0 +1,827 @@
|
|||||||
|
# Buzz Architecture
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Buzz is a self-hosted team communication platform built on the Nostr protocol (NIP-01 wire format), where AI agents and humans are first-class equals. Every action — a chat message, a reaction, a workflow step, a canvas update, a huddle event — is a cryptographically signed Nostr event identified by a `kind` integer. Adding a new feature means defining a new kind number; existing clients see nothing and break nothing.
|
||||||
|
|
||||||
|
The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket, and the relay enforcing auth, verifying signatures, persisting events, fanning out to subscribers, indexing for search, and triggering automation.
|
||||||
|
|
||||||
|
A Buzz **community** is the tenant-visible workspace selected by the request host.
|
||||||
|
The self-hosted default remains one host, one relay process, one implicit
|
||||||
|
community. Multi-community deployments move that semantic boundary one level up:
|
||||||
|
`req.community = resolve_host(connection.host)` is established before AUTH,
|
||||||
|
EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown
|
||||||
|
hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived
|
||||||
|
community rather than overriding it.
|
||||||
|
|
||||||
|
Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CLIENTS │
|
||||||
|
│ │
|
||||||
|
│ Human (Nostr app, web, mobile) Agent (CLI tools via buzz-cli) │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────── WebSocket ─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ buzz-relay (Axum) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ NIP-42 │ │ EVENT │ │ REQ │ │ HTTP bridge │ │
|
||||||
|
│ │ auth │ │ pipeline │ │ handler │ │ /events │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │ /query │ │
|
||||||
|
│ │ /count │ │
|
||||||
|
│ ┌──────────────────────────────────────┐ │ /hooks/{id} │ │
|
||||||
|
│ │ SubscriptionRegistry │ │ /media/* │ │
|
||||||
|
│ │ DashMap: (channel_id, kind) → conns │ │ /git/* │ │
|
||||||
|
│ └──────────────────────────────────────┘ │ /info, NIP-05 │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└──────────┬──────────────┬──────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌─────▼──────┐ ┌────▼──────┐
|
||||||
|
│ Postgres │ │ Redis │
|
||||||
|
│ (events, │ │ (presence │
|
||||||
|
│ channels, │ │ SET EX, │
|
||||||
|
│ tokens, │ │ typing │
|
||||||
|
│ workflows, │ │ ZADD, │
|
||||||
|
│ audit) │ │ PUBLISH) │
|
||||||
|
└────────────┘ └───────────┘
|
||||||
|
|
||||||
|
Fan-out: sub_registry.fan_out() → conn_manager.send_to()
|
||||||
|
(in-process for local events; Redis round-trip for
|
||||||
|
events from other relay instances)
|
||||||
|
|
||||||
|
Redis PUBLISH occurs for channel-scoped events.
|
||||||
|
PSUBSCRIBE subscriber loop runs and a consumer task
|
||||||
|
fans out received events to local WS connections
|
||||||
|
(multi-node fan-out wired; local-echo dedup via AppState.local_event_ids).
|
||||||
|
|
||||||
|
┌──────────────┐
|
||||||
|
│ Postgres │ ← buzz-search (FTS over the search_tsv
|
||||||
|
│ (full-text │ generated column + GIN index)
|
||||||
|
│ search) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Crate Dependency Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
buzz-core (zero I/O — types, verification, filter matching, kind registry)
|
||||||
|
│
|
||||||
|
├── buzz-db (Postgres: events, channels, tokens, workflows, audit)
|
||||||
|
├── buzz-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
|
||||||
|
├── buzz-pubsub (Redis pub/sub, presence, typing indicators)
|
||||||
|
├── buzz-search (Postgres FTS: query, delete)
|
||||||
|
├── buzz-audit (hash-chain tamper-evident log)
|
||||||
|
└── buzz-workflow (YAML-as-code automation engine)
|
||||||
|
│
|
||||||
|
└── buzz-relay (ties everything together — the server)
|
||||||
|
|
||||||
|
buzz-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
|
||||||
|
buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli)
|
||||||
|
buzz-media (Blossom/S3 media storage)
|
||||||
|
buzz-cli (agent-first CLI)
|
||||||
|
buzz-admin (operator CLI: relay membership + key generation)
|
||||||
|
buzz-test-client (integration test harness + manual CLI)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Protocol
|
||||||
|
|
||||||
|
Buzz uses Nostr NIP-01 on the wire. Every action is a JSON event with six fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<sha256 of canonical serialization>",
|
||||||
|
"pubkey": "<secp256k1 public key, hex>",
|
||||||
|
"kind": <unsigned integer>,
|
||||||
|
"tags": [["e", "<event-id>"], ["p", "<pubkey>"], ...],
|
||||||
|
"content": "<JSON payload or plain text>",
|
||||||
|
"sig": "<Schnorr signature over id>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `kind` integer is the only dispatch switch. The relay routes, stores, and fans out events based on kind. Clients filter subscriptions by kind. New feature = new kind number = zero breaking changes to existing clients.
|
||||||
|
|
||||||
|
### Kind Ranges
|
||||||
|
|
||||||
|
| Range | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| 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 each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
|
||||||
|
|
||||||
|
Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`.
|
||||||
|
|
||||||
|
### Wire Protocol (NIP-01 messages)
|
||||||
|
|
||||||
|
| Direction | Message | Purpose |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| Client → Relay | `["EVENT", <event>]` | Submit a signed event |
|
||||||
|
| Client → Relay | `["REQ", <sub_id>, <filter>, ...]` | Subscribe to events |
|
||||||
|
| Client → Relay | `["CLOSE", <sub_id>]` | Cancel a subscription |
|
||||||
|
| Client → Relay | `["AUTH", <event>]` | Authenticate (NIP-42) |
|
||||||
|
| Relay → Client | `["EVENT", <sub_id>, <event>]` | Deliver a matching event |
|
||||||
|
| Relay → Client | `["EOSE", <sub_id>]` | End of stored events |
|
||||||
|
| Relay → Client | `["OK", <event_id>, true/false, ""]` | Event acceptance result |
|
||||||
|
| Relay → Client | `["CLOSED", <sub_id>, "reason"]` | Subscription closed |
|
||||||
|
| Relay → Client | `["NOTICE", "message"]` | Informational message |
|
||||||
|
| Relay → Client | `["AUTH", <challenge>]` | Authentication challenge |
|
||||||
|
|
||||||
|
Max frame size: 65,536 bytes. Max subscriptions per connection: 1024. Max historical results per filter: 500.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Connection Lifecycle
|
||||||
|
|
||||||
|
Every WebSocket connection follows this exact sequence:
|
||||||
|
|
||||||
|
### Step 0: Community Binding
|
||||||
|
|
||||||
|
The server resolves `TenantContext` from the request host before any handler can
|
||||||
|
observe tenant data. The URL/domain is authoritative for the community, matching
|
||||||
|
today's "the relay URL is the workspace" behavior. In single-community mode the
|
||||||
|
configured host maps to the default community. In multi-community mode, an
|
||||||
|
unknown or unmapped host rejects generically and never falls through to a default
|
||||||
|
tenant. Client-supplied `#h` tags are still channel identifiers; they must resolve
|
||||||
|
to a channel inside the host-derived community.
|
||||||
|
|
||||||
|
### Step 1: Semaphore Acquire
|
||||||
|
|
||||||
|
`state.conn_semaphore.try_acquire_owned()` — if the relay is at connection capacity, the connection is rejected immediately before any data is read. The permit is held for the entire connection lifetime and dropped on cleanup.
|
||||||
|
|
||||||
|
### Step 2: NIP-42 Challenge
|
||||||
|
|
||||||
|
The relay immediately sends `["AUTH", "<challenge>"]`. The challenge is a random string. The connection is registered in `ConnectionManager` after the challenge is sent.
|
||||||
|
|
||||||
|
### Step 3: Authentication
|
||||||
|
|
||||||
|
The client must respond with `["AUTH", <signed-event>]` before submitting events or subscriptions. Authentication paths:
|
||||||
|
|
||||||
|
| Path | Mechanism | Use Case |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
|
||||||
|
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients |
|
||||||
|
|
||||||
|
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
|
||||||
|
|
||||||
|
### Step 4: Active Loops
|
||||||
|
|
||||||
|
Three concurrent tasks run for the lifetime of the connection:
|
||||||
|
|
||||||
|
- **recv_loop** (inline): reads frames, parses `ClientMessage`, dispatches to handlers
|
||||||
|
- **send_loop** (spawned): drains the mpsc channel, writes frames to the WebSocket
|
||||||
|
- **heartbeat_loop** (spawned): sends WebSocket ping every 30 seconds; 3 missed pongs → disconnect
|
||||||
|
|
||||||
|
A `CancellationToken` coordinates shutdown across all three loops.
|
||||||
|
|
||||||
|
Slow clients: `ConnectionState::send()` uses `try_send` — if the send buffer is full, a grace counter increments. After `SLOW_CLIENT_GRACE_LIMIT` (3) consecutive full-buffer events, the connection is cancelled. A successful send resets the counter.
|
||||||
|
|
||||||
|
### Step 5: Cleanup
|
||||||
|
|
||||||
|
On disconnect (any cause):
|
||||||
|
1. `cancel.cancel()` — signals all loops
|
||||||
|
2. Await send_loop and heartbeat_loop tasks
|
||||||
|
3. `sub_registry.remove_connection(conn_id)` — removes all subscriptions from the DashMap indexes
|
||||||
|
4. `conn_manager.deregister(conn_id)` — removes from the send-channel map
|
||||||
|
5. `drop(permit)` — releases the connection semaphore slot
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Event Pipeline
|
||||||
|
|
||||||
|
When the relay receives `["EVENT", <event>]`, the handler in `handlers/event.rs` runs this pipeline in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. AUTH CHECK — AuthState::Authenticated? MessagesWrite scope?
|
||||||
|
2. PUBKEY MATCH — event.pubkey == auth_context.pubkey?
|
||||||
|
3. KIND_AUTH REJECT — kind == 22242 (AUTH events never stored)
|
||||||
|
4. EPHEMERAL ROUTE — kind 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 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap.
|
||||||
|
|
||||||
|
**Typing indicators:**
|
||||||
|
```
|
||||||
|
ZADD buzz:typing:{channel_id} {now_unix} {pubkey_hex}
|
||||||
|
ZREMRANGEBYSCORE buzz:typing:{channel_id} -inf {now - 5.0}
|
||||||
|
EXPIRE buzz:typing:{channel_id} 60
|
||||||
|
```
|
||||||
|
5-second activity window. 60-second key TTL prevents orphaned empty sets.
|
||||||
|
|
||||||
|
**Does NOT:** implement the rate limiter. Does NOT store events. `PubSubManager` is not `Clone` — callers use `Arc<PubSubManager>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### buzz-search — Postgres FTS Integration
|
||||||
|
|
||||||
|
Full-text search via Postgres FTS. Events are searchable through the
|
||||||
|
`events.search_tsv` generated `tsvector` column (populated on insert, indexed
|
||||||
|
by a GIN index) — there is no separate search service or out-of-band indexer.
|
||||||
|
Privacy-sensitive kinds are excluded at the storage level (the `search_tsv`
|
||||||
|
`CASE WHEN kind IN (...)` yields `NULL`, which never matches `@@`). In
|
||||||
|
multi-community mode every query filter includes `community_id`, so the shared
|
||||||
|
`events` table is infrastructure, not a cross-community result space; the relay
|
||||||
|
re-authorizes every candidate hit before returning it.
|
||||||
|
|
||||||
|
**Key behaviors:**
|
||||||
|
- `SearchService::new(pool)` wraps a `PgPool`; `search(&SearchQuery)` runs a
|
||||||
|
parameterized FTS query against the `events.search_tsv` GIN index and returns
|
||||||
|
`SearchResult` (candidate `SearchHit`s).
|
||||||
|
- `ChannelScope` makes the channel constraint explicit (`Any` /
|
||||||
|
`ChannelLessOnly` / `Channels` / `ChannelsOrChannelLess`), closing the
|
||||||
|
ambiguity the old `Option<Vec<Uuid>> + bool` matrix could not express.
|
||||||
|
- Every query carries `community_id`; the FTS predicate is BitmapAnd-ed with
|
||||||
|
the community-leading btree filters so a query never crosses tenants.
|
||||||
|
- Permission filtering is **caller's responsibility** — `buzz-search` returns
|
||||||
|
candidate hits; the relay re-authorizes each one (channel membership, `#p`,
|
||||||
|
owner gates) before delivering it.
|
||||||
|
|
||||||
|
**Does NOT:** enforce channel membership or access control. Does NOT write
|
||||||
|
events (indexing is the `search_tsv` generated column on the `events` insert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### buzz-audit — Hash-Chain Audit Log
|
||||||
|
|
||||||
|
Tamper-evident append-only log with SHA-256 hash chaining.
|
||||||
|
|
||||||
|
**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). In multi-community mode audit heads/chains are per-community; operator metrics may aggregate, but tenant-readable audit verification walks one community chain. `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros).
|
||||||
|
|
||||||
|
**Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash.
|
||||||
|
|
||||||
|
**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`).
|
||||||
|
|
||||||
|
**10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`.
|
||||||
|
|
||||||
|
**Does NOT:** log `KIND_AUTH` (22242) events — returns `AuditError::AuthEventForbidden` immediately. Does NOT log ephemeral events (they never reach the audit pipeline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### buzz-workflow — YAML-as-Code Automation Engine
|
||||||
|
|
||||||
|
Parses, validates, and executes channel-scoped workflow definitions. In multi-community mode workflow definitions, runs, approvals, webhook routes, and schedules inherit the host-derived community and evaluate triggers only against events in that community.
|
||||||
|
|
||||||
|
**Workflow definition structure:**
|
||||||
|
```yaml
|
||||||
|
name: "Incident Triage"
|
||||||
|
trigger:
|
||||||
|
on: message_posted
|
||||||
|
filter: "str_contains(trigger_text, 'P1')"
|
||||||
|
steps:
|
||||||
|
- id: notify
|
||||||
|
action: send_message
|
||||||
|
text: "P1 incident detected: {{trigger.text}}"
|
||||||
|
- id: page
|
||||||
|
if: "str_contains(trigger_text, 'production')"
|
||||||
|
action: request_approval
|
||||||
|
from: "{{trigger.author}}"
|
||||||
|
message: "Page on-call?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Triggers use `on:` as the tag field; actions use `action:` as the tag field. Fields are flattened into the parent struct, not nested.
|
||||||
|
|
||||||
|
**4 trigger types:** `message_posted`, `reaction_added`, `schedule`, `webhook`
|
||||||
|
|
||||||
|
**7 action types:**
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `send_message` | Post to the workflow's channel (or override channel) |
|
||||||
|
| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) |
|
||||||
|
| `set_channel_topic` | Update channel topic |
|
||||||
|
| `add_reaction` | React to the trigger message |
|
||||||
|
| `call_webhook` | HTTP POST to external URL (SSRF-protected, redirects disabled, 1 MiB response cap) |
|
||||||
|
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
|
||||||
|
| `delay` | Pause execution (max 300 seconds) |
|
||||||
|
|
||||||
|
**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.
|
||||||
|
|
||||||
|
**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.
|
||||||
|
|
||||||
|
**Concurrency:** `Arc<Semaphore>` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing.
|
||||||
|
|
||||||
|
**Approval gates:** `request_approval` action returns `StepResult::Suspended` with a generated UUID token, but the engine does not yet persist the token or resume execution — runs that hit an approval gate are marked as failed (🚧 WF-08). `execute_from_step()` exists for future resumption support.
|
||||||
|
|
||||||
|
**Cron scheduler:** loop ticks every 60 seconds, evaluates cron expressions with window-based matching, and creates workflow runs for matched triggers. Fully implemented.
|
||||||
|
|
||||||
|
**Does NOT:** recursively resolve templates (single-pass only). Does NOT queue workflow runs when at capacity — returns `CapacityExceeded` immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Huddle Audio — WebSocket Opus Relay
|
||||||
|
|
||||||
|
Real-time voice lives inside `buzz-relay` (`src/audio/`), not a separate crate. A WebSocket endpoint (`wss://.../huddle/{channel_id}/audio`) authenticates each participant with a NIP-42 challenge, checks channel membership, admits them to an in-memory room, and forwards opaque Opus frames between peers. No external SFU.
|
||||||
|
|
||||||
|
**Frame protocol (v2):** 8-byte big-endian header (sequence `u16`, 48 kHz timestamp `u32`, level dBov `i8`, flags `u8`) followed by an opaque Opus payload. Invalid `level_dbov` values are clamped rather than dropped — losing a metric beats losing audio.
|
||||||
|
|
||||||
|
**Room state:** an admission guard synchronizes joins against the room's ended flag; soft cap 25 peers (hard cap 255 via `u8` peer index). Per-peer audio uses a bounded channel (drop-on-full); the control channel is separate and never drops join/leave.
|
||||||
|
|
||||||
|
**Lifecycle events:** the relay emits Nostr events for participant joined / left and huddle ended; the desktop client emits huddle started and guidelines. When the last peer leaves, the room ends and the channel archives atomically.
|
||||||
|
|
||||||
|
**Not yet built:** recording and per-track publishing (the corresponding kinds are reserved, no producer exists).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### buzz-relay — The Server
|
||||||
|
|
||||||
|
Axum WebSocket server. Ties all other crates together. The only crate that imports and orchestrates all subsystems.
|
||||||
|
|
||||||
|
**`AppState`** (Arc-wrapped, shared across all connections — key fields shown, not exhaustive):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Db,
|
||||||
|
pub audit: Arc<AuditService>,
|
||||||
|
pub pubsub: Arc<PubSubManager>,
|
||||||
|
pub auth: Arc<AuthService>,
|
||||||
|
pub search: Arc<SearchService>,
|
||||||
|
pub sub_registry: Arc<SubscriptionRegistry>,
|
||||||
|
pub conn_manager: Arc<ConnectionManager>,
|
||||||
|
pub workflow_engine: Arc<WorkflowEngine>,
|
||||||
|
pub conn_semaphore: Arc<Semaphore>, // connection limit
|
||||||
|
pub handler_semaphore: Arc<Semaphore>, // 1024 concurrent handlers
|
||||||
|
pub relay_keypair: nostr::Keys, // relay identity
|
||||||
|
pub local_event_ids: moka::sync::Cache, // local-echo dedup
|
||||||
|
pub search_index_tx: mpsc::Sender, // bounded search worker queue
|
||||||
|
// + config, redis_pool, membership_cache, media_storage, shutdown state
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ConnectionState`** (per-connection):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ConnectionState {
|
||||||
|
pub auth_state: RwLock<AuthState>,
|
||||||
|
pub subscriptions: Mutex<HashMap<String, Vec<Filter>>>,
|
||||||
|
// + send_tx, cancel token
|
||||||
|
}
|
||||||
|
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP endpoints:**
|
||||||
|
|
||||||
|
| Method | Path | Handler |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/` | WebSocket upgrade or NIP-11 relay info |
|
||||||
|
| GET | `/info` | NIP-11 relay info |
|
||||||
|
| GET | `/.well-known/nostr.json` | NIP-05 identity |
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
| GET | `/_liveness` | Liveness probe |
|
||||||
|
| GET | `/_readiness` | Readiness probe |
|
||||||
|
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
|
||||||
|
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
|
||||||
|
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
|
||||||
|
| POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) |
|
||||||
|
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
|
||||||
|
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
|
||||||
|
| GET | `/git/{owner}/{repo}/info/refs` | Git smart HTTP advertisement |
|
||||||
|
| POST | `/git/{owner}/{repo}/git-upload-pack` | Git smart HTTP fetch |
|
||||||
|
| POST | `/git/{owner}/{repo}/git-receive-pack` | Git smart HTTP push |
|
||||||
|
| POST | `/internal/git/policy` | Internal git hook policy check |
|
||||||
|
|
||||||
|
**Constants:**
|
||||||
|
|
||||||
|
| Constant | Value | Purpose |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `MAX_FRAME_BYTES` | 65,536 | Max WebSocket frame size |
|
||||||
|
| `MAX_SUBSCRIPTIONS` | 1024 | Per-connection subscription limit |
|
||||||
|
| `MAX_HISTORICAL_LIMIT` | 500 | Per-filter historical query cap |
|
||||||
|
| `handler_semaphore` capacity | 1024 | Concurrent EVENT/REQ handlers |
|
||||||
|
|
||||||
|
**Does NOT:** implement business logic — delegates to the appropriate crate for every operation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### buzz-acp — Agent Communication Protocol Harness
|
||||||
|
|
||||||
|
Standalone binary that bridges Buzz relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP).
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Buzz Relay ──WS──→ buzz-acp ──stdio (ACP/JSON-RPC)──→ Agent (goose/codex/claude)
|
||||||
|
```
|
||||||
|
|
||||||
|
`buzz-acp` spawns AI agent subprocesses (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 | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
|
||||||
|
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
|
||||||
|
|
||||||
|
### Full-Text Search (Postgres FTS)
|
||||||
|
|
||||||
|
Search runs over the `events.search_tsv` generated `tsvector` column on the
|
||||||
|
`events` table (no separate collection or service). The column is populated on
|
||||||
|
insert — `to_tsvector('simple', content)` — and excludes privacy-sensitive
|
||||||
|
kinds via `CASE WHEN kind IN (1059, 30300, 30622) THEN NULL`, so those rows are
|
||||||
|
storage-level unsearchable (a `NULL` tsvector never matches `@@`). A GIN index
|
||||||
|
(`idx_events_search_tsv`) backs the `@@` probe; in multi-community mode the
|
||||||
|
community-leading btree filters BitmapAnd with the GIN probe so every query is
|
||||||
|
fenced to its `community_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Known Limitations
|
||||||
|
|
||||||
|
These are verified gaps in the current implementation — not design aspirations.
|
||||||
|
|
||||||
|
| # | Limitation | Detail |
|
||||||
|
|---|-----------|--------|
|
||||||
|
| 1 | **No sqlx offline query cache** | Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). No `.sqlx/` directory. Queries are not validated at compile time. |
|
||||||
|
| 2 | **No rate limiting implementation** | `RateLimiter` trait exists in `buzz-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. |
|
||||||
|
| 3 | **No dedicated typing REST endpoint** | Typing indicators (kind 20002) are delivered via both local fan-out and Redis pub/sub (cross-node). There is no REST endpoint to query current typers — `/api/presence` returns online/away status only, not typing state. |
|
||||||
|
| 4 | **Huddle recording/tracks not built** | Voice, room lifecycle, and join/leave/end events are wired (see Huddle Audio above). Recording and per-track publishing have reserved kinds but no producer yet. |
|
||||||
|
| 5 | **Approval gates not wired end-to-end** | The executor returns `StepResult::Suspended` and the relay has grant/deny API endpoints with DB CRUD, but the engine intercepts before creating `WaitingApproval` rows — runs that hit an approval gate are marked as Failed (🚧 WF-08). |
|
||||||
|
| 6 | **Workflow actions partially stubbed** | The `send_dm` and `set_channel_topic` workflow actions are in the schema but return `NotImplemented` — a run that reaches one fails at execution (🚧 WF-07). |
|
||||||
+1615
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or electronic
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
**conduct@buzz-relay.org**.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
+529
@@ -0,0 +1,529 @@
|
|||||||
|
# Contributing to Buzz
|
||||||
|
|
||||||
|
Welcome, and thank you for your interest in contributing! Buzz is an
|
||||||
|
open-source project and we're glad you're here. This guide will help you
|
||||||
|
get from zero to a merged pull request.
|
||||||
|
|
||||||
|
If you have questions that aren't answered here, [open an issue](https://github.com/block/buzz/issues/new).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Code of Conduct](#code-of-conduct)
|
||||||
|
2. [Before You Open a PR](#before-you-open-a-pr)
|
||||||
|
3. [Setting Up the Development Environment](#setting-up-the-development-environment)
|
||||||
|
4. [Running Tests](#running-tests)
|
||||||
|
5. [Code Style](#code-style)
|
||||||
|
6. [Making a Pull Request](#making-a-pull-request)
|
||||||
|
7. [Architecture Overview](#architecture-overview)
|
||||||
|
8. [Ecosystem](#ecosystem)
|
||||||
|
9. [How to Add a New Event Kind](#how-to-add-a-new-event-kind)
|
||||||
|
10. [How to Add a New MCP Tool](#how-to-add-a-new-mcp-tool)
|
||||||
|
11. [How to Add a New API Endpoint](#how-to-add-a-new-api-endpoint)
|
||||||
|
12. [License and CLA](#license-and-cla)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant v2.1](CODE_OF_CONDUCT.md).
|
||||||
|
By participating you agree to uphold these standards. Please report
|
||||||
|
unacceptable behavior to **conduct@buzz-relay.org**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before You Open a PR
|
||||||
|
|
||||||
|
Before starting, search [open PRs](https://github.com/block/buzz/pulls) and [open issues](https://github.com/block/buzz/issues) for duplicates — someone may already be working on the same thing. When you open your PR, link the closest existing one in the description (or say "none found").
|
||||||
|
|
||||||
|
For anything beyond a small fix, opening an issue first is strongly recommended. Describe the problem and proposed solution so a maintainer can acknowledge the approach before you build — it avoids two people building the same thing in parallel.
|
||||||
|
|
||||||
|
Buzz is an agent platform, so AI-assisted PRs are welcome. No need to disclose the tools you used, but you own and must have reviewed the final code. Submissions that are clearly unreviewed may be closed with a pointer here.
|
||||||
|
|
||||||
|
We squash-merge, so your PR title becomes the commit subject in `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat(mcp): add get_feed_actions tool`. The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is required. See the [Commit Messages](#commit-messages) section for the full reference.
|
||||||
|
|
||||||
|
### Sign Your Commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -s
|
||||||
|
```
|
||||||
|
|
||||||
|
Every commit needs a Developer Certificate of Origin (DCO) sign-off. The `-s` flag appends a `Signed-off-by` trailer that certifies you wrote the change and can contribute it under the project license. The **DCO Check** will block your PR without it.
|
||||||
|
|
||||||
|
#### Fix unsigned commits already pushed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rebase --signoff main
|
||||||
|
git push --force-with-lease
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Auto-setup for future commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a `commit-msg` hook that adds the sign-off trailer automatically for `git commit` and `git merge`. Other flows (`git rebase`, `git cherry-pick`) still need their own flag — `--signoff` and `-s` respectively.
|
||||||
|
|
||||||
|
We review as capacity allows — focused PRs that follow this guide move fastest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting Up the Development Environment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Tool | Version | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Rust | 1.88+ | Install via [rustup](https://rustup.rs/) |
|
||||||
|
| Node.js | 24+ | Required for desktop app commands and `just ci` |
|
||||||
|
| pnpm | 10+ | Required for desktop app commands and `just ci` |
|
||||||
|
| Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) |
|
||||||
|
| Docker | 24+ | For Postgres, Redis, MinIO |
|
||||||
|
| `just` | latest | Task runner — `cargo install just` |
|
||||||
|
| `lefthook` | 2.1.3 (Hermit-pinned) | Auto-installed by `just hooks` — no manual install needed |
|
||||||
|
| `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` |
|
||||||
|
|
||||||
|
This repo uses [Hermit](https://cashapp.github.io/hermit/) for toolchain
|
||||||
|
pinning. Activate it once per shell session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. ./bin/activate-hermit
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in
|
||||||
|
`bin/`. Each tool is downloaded on first use. You can also run `just bootstrap`
|
||||||
|
(which `just setup` calls automatically) to pre-download all required tools
|
||||||
|
upfront. If you don't use Hermit, ensure your toolchain meets the minimum
|
||||||
|
versions in the table above.
|
||||||
|
|
||||||
|
#### Linux: Tauri system libraries
|
||||||
|
|
||||||
|
Hermit pins language toolchains, not system libraries. On Linux, the desktop
|
||||||
|
app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any
|
||||||
|
`just desktop-tauri-*` recipe) needs these installed system-wide first. On
|
||||||
|
Debian/Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
build-essential curl file libasound2-dev libayatana-appindicator3-dev \
|
||||||
|
libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \
|
||||||
|
patchelf wget
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same list CI installs (see `.github/workflows/ci.yml`), so matching
|
||||||
|
it locally keeps your results comparable to CI. Other distributions ship these
|
||||||
|
under different package names — see the
|
||||||
|
[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
Without them, `just ci` fails partway through `just check` with a pkg-config
|
||||||
|
error such as:
|
||||||
|
|
||||||
|
```
|
||||||
|
The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found.
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're only touching the relay, CLI, or other server-side crates, you can
|
||||||
|
skip this and run the narrower recipes instead — `just fmt-check`, `just
|
||||||
|
clippy`, `just test-unit`, and `just test` need no GTK.
|
||||||
|
|
||||||
|
### First-Time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repo
|
||||||
|
git clone https://github.com/block/buzz.git
|
||||||
|
cd buzz
|
||||||
|
|
||||||
|
# 2. Activate Hermit (optional but recommended)
|
||||||
|
. ./bin/activate-hermit
|
||||||
|
|
||||||
|
# 3. Bootstrap tools + infrastructure
|
||||||
|
just setup
|
||||||
|
|
||||||
|
# 4. Install Git hooks (optional, recommended)
|
||||||
|
just hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
`just setup` runs `just bootstrap` first — it copies `.env.example` to `.env`
|
||||||
|
if it doesn't already exist, and invokes `cargo`, `node`, and `pnpm` to trigger
|
||||||
|
Hermit's lazy tool download (each tool is fetched once on first invocation and
|
||||||
|
cached thereafter). You can also run `just bootstrap` independently at any time;
|
||||||
|
it is safe to re-run.
|
||||||
|
|
||||||
|
`just setup` then starts Docker services (Postgres on `:5432`, Redis on `:6379`,
|
||||||
|
Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on
|
||||||
|
`:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all
|
||||||
|
pending database migrations.
|
||||||
|
|
||||||
|
### Running the Relay and Desktop App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just dev # starts the relay + desktop app in one command
|
||||||
|
```
|
||||||
|
|
||||||
|
`just dev` builds all agent tools, starts the relay (`ws://localhost:3000`) in
|
||||||
|
the background, and launches the Tauri desktop app. The relay process is
|
||||||
|
automatically killed when you quit the app or press Ctrl+C.
|
||||||
|
|
||||||
|
For a split-terminal workflow (relay logs visible separately from Vite output):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just relay # terminal 1 — relay on ws://localhost:3000
|
||||||
|
just desktop-dev # terminal 2 — Vite dev server only (no Tauri shell)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping / Resetting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just down # Stop Docker services, keep data
|
||||||
|
just reset # Wipe all dev state and recreate it; installed Buzz is preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
Development desktop state uses separate bundle identifiers
|
||||||
|
(`xyz.block.buzz.app.dev` and per-worktree variants), a separate keyring service
|
||||||
|
(`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those dev-only
|
||||||
|
locations and the local Docker volumes. It does not touch the installed app's
|
||||||
|
`xyz.block.buzz.app` data, `buzz-desktop` keyring service, or `~/.buzz` nest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Unit Tests (no infrastructure required)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test-unit
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit tests are self-contained and run without Docker. They cover event
|
||||||
|
parsing, filter matching, auth logic, workflow YAML parsing, and more.
|
||||||
|
|
||||||
|
### Integration Tests (requires running infrastructure)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests spin up the relay and exercise the full stack — WebSocket
|
||||||
|
connections, NIP-42 auth, event ingestion, search indexing, and workflow
|
||||||
|
execution. `just test` starts Docker services automatically if they're not
|
||||||
|
already running.
|
||||||
|
|
||||||
|
### End-to-End Tests
|
||||||
|
|
||||||
|
End-to-end tests live in `crates/buzz-test-client/tests/`:
|
||||||
|
|
||||||
|
- `e2e_relay.rs` — WebSocket relay tests
|
||||||
|
- `e2e_mcp.rs` — MCP tool tests
|
||||||
|
- `e2e_nostr_interop.rs` — Nostr protocol interoperability tests
|
||||||
|
- `e2e_media.rs` — media upload/download tests
|
||||||
|
- `e2e_media_extended.rs` — extended media tests (GIF, image processing)
|
||||||
|
|
||||||
|
Run them with (requires running infrastructure):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p buzz-test-client -- --ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
See `TESTING.md` for the full multi-agent E2E testing guide.
|
||||||
|
|
||||||
|
### CI Gate
|
||||||
|
|
||||||
|
Before opening a PR, run the full CI gate locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just ci
|
||||||
|
# Runs: check + unit tests + desktop build + Tauri check + mobile tests
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same check that runs in CI. PRs that fail `just ci` will not be
|
||||||
|
merged. If `just ci` fails on formatting, `just fix-all` fixes it in one shot (`rustfmt` + Tauri fmt + desktop, web, and mobile formatters).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
We use `rustfmt` with default settings. Format your code before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
```
|
||||||
|
|
||||||
|
To check without modifying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
We use `clippy` with warnings-as-errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix all clippy warnings before submitting a PR. If you believe a warning is
|
||||||
|
a false positive, add a targeted `#[allow(...)]` with a comment explaining
|
||||||
|
why.
|
||||||
|
|
||||||
|
### No Unsafe Code
|
||||||
|
|
||||||
|
All crates enforce `#![deny(unsafe_code)]`. Do not add unsafe blocks. If you
|
||||||
|
believe unsafe is genuinely necessary, open an issue first to discuss the
|
||||||
|
approach.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use `thiserror` for library error types.
|
||||||
|
- Use `anyhow` for binary / application-level error propagation.
|
||||||
|
- Do not use `unwrap()` or `expect()` in production code paths. Use `?` or
|
||||||
|
explicit error handling. `unwrap()` is acceptable in tests.
|
||||||
|
|
||||||
|
### Logging and Tracing
|
||||||
|
|
||||||
|
Use the `tracing` crate for all instrumentation. Prefer structured fields
|
||||||
|
over string interpolation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Good
|
||||||
|
tracing::info!(channel_id = %id, event_kind = kind, "Event ingested");
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
tracing::info!("Event ingested: channel={id} kind={kind}");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(mcp): add get_feed_actions tool
|
||||||
|
fix(auth): reject expired NIP-42 challenges
|
||||||
|
docs(agents): document workflow MCP tools
|
||||||
|
refactor(db): extract channel queries into channel.rs
|
||||||
|
test(workflow): add approval gate integration test
|
||||||
|
```
|
||||||
|
|
||||||
|
The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is
|
||||||
|
required. The scope (in parentheses) is optional but encouraged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making a Pull Request
|
||||||
|
|
||||||
|
### What a Good PR Looks Like
|
||||||
|
|
||||||
|
1. **Focused** — one logical change per PR. If you're fixing a bug and
|
||||||
|
refactoring a module, split them into two PRs.
|
||||||
|
|
||||||
|
2. **Tested** — new behavior has tests. Bug fixes include a regression test.
|
||||||
|
If a test is impractical, explain why in the PR description.
|
||||||
|
|
||||||
|
3. **Documented** — public APIs, new event kinds, new MCP tools, and new
|
||||||
|
config variables are documented. Update `README.md`, `AGENTS.md`, or
|
||||||
|
`VISION.md` as appropriate.
|
||||||
|
|
||||||
|
4. **CI passing** — `just ci` passes locally before you push.
|
||||||
|
|
||||||
|
5. **Clear description** — the PR description explains:
|
||||||
|
- What problem this solves (or what feature it adds)
|
||||||
|
- How it was implemented (key decisions, trade-offs)
|
||||||
|
- How to test it manually (if applicable)
|
||||||
|
- Any follow-up work deferred to a future PR
|
||||||
|
|
||||||
|
6. **Shows the UI** — any PR that changes the desktop or mobile UI includes
|
||||||
|
before/after screenshots (or a short recording for interactions) in the
|
||||||
|
description. We can't run every branch locally — screenshots let us review
|
||||||
|
UI changes same-day instead of waiting for someone to build your branch.
|
||||||
|
|
||||||
|
### PRs We're Unlikely to Merge
|
||||||
|
|
||||||
|
Some kinds of PRs usually get closed — not because they're bad ideas, but
|
||||||
|
because we can't safely review them without prior discussion:
|
||||||
|
|
||||||
|
- **Large refactors or dependency swaps** without a prior issue agreeing on
|
||||||
|
the direction
|
||||||
|
- **Cosmetic renames or style-only churn** that doesn't fix a bug or improve
|
||||||
|
clarity
|
||||||
|
- **Entirely new features** with no prior discussion
|
||||||
|
- **Drive-by changes bundled into an unrelated fix** — split them out
|
||||||
|
|
||||||
|
If you're considering any of these, open an issue first and we'll tell you
|
||||||
|
quickly whether it's a direction we'd merge. That saves your time as much as
|
||||||
|
ours.
|
||||||
|
|
||||||
|
### What to Expect After You Open a PR
|
||||||
|
|
||||||
|
- Maintainers triage new PRs on a best-effort cadence. Focused PRs that
|
||||||
|
follow this guide move fastest.
|
||||||
|
- Duplicates and PRs that skip this guide may be closed with a pointer here
|
||||||
|
rather than a full review. A close isn't a rejection of you or the idea —
|
||||||
|
address the gaps and reopen (or open a fresh PR) anytime.
|
||||||
|
- Address review comments by pushing new commits (don't force-push during
|
||||||
|
review; it makes it hard to see what changed).
|
||||||
|
- Once approved, a maintainer will squash-merge your PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design and
|
||||||
|
[AGENTS.md](AGENTS.md#repo-structure) for the complete crate map. The key
|
||||||
|
design principles:
|
||||||
|
|
||||||
|
**The relay is the single source of truth.** All state flows through the
|
||||||
|
event store. Crates communicate through the database and Redis pub/sub — not
|
||||||
|
through direct function calls across crate boundaries (with the exception
|
||||||
|
of `buzz-core` types, which are shared everywhere).
|
||||||
|
|
||||||
|
**Event kinds are the only switch.** Every action in the system — a message,
|
||||||
|
a reaction, a workflow step, a canvas update — is a Nostr event with a kind
|
||||||
|
integer. Adding a new feature means defining a new kind. No breaking changes
|
||||||
|
to existing clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
Buzz is developed across multiple repositories. This repo (`block/buzz`)
|
||||||
|
is the open-source home for all application code — the relay, desktop app,
|
||||||
|
mobile app, CLI, and agent harness. Internal repositories handle
|
||||||
|
enterprise-signed builds and infrastructure deployment.
|
||||||
|
|
||||||
|
See [AGENTS.md § Ecosystem](AGENTS.md#ecosystem) for the full repo table and
|
||||||
|
dependency diagram.
|
||||||
|
|
||||||
|
**External contributors:** Fork `block/buzz`, open a PR, and CI runs
|
||||||
|
automatically. No special access is required.
|
||||||
|
|
||||||
|
**Block team members:** See the internal
|
||||||
|
[sprout-releases CONTRIBUTING.md](https://github.com/squareup/sprout-releases/blob/main/CONTRIBUTING.md)
|
||||||
|
for team access setup, onboarding, and the full repo inventory. See
|
||||||
|
[RELEASING.md](RELEASING.md) for the release process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Add a New Event Kind
|
||||||
|
|
||||||
|
1. **Define the kind constant** in `buzz-core/src/kind.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// My new event kind — description of what it represents.
|
||||||
|
pub const KIND_MY_FEATURE: u32 = 4XXXX;
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a kind number in the appropriate sub-range defined in `kind.rs`.
|
||||||
|
Check the `ALL_KINDS` array for collisions. Each sub-range is documented
|
||||||
|
with comments in the file.
|
||||||
|
|
||||||
|
2. **Define the payload type** in the appropriate module in `buzz-core/src/`
|
||||||
|
(e.g., alongside `event.rs`) if the content field is structured JSON:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MyFeaturePayload {
|
||||||
|
pub field_one: String,
|
||||||
|
pub field_two: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Register the kind's required scope** in
|
||||||
|
`crates/buzz-relay/src/handlers/ingest.rs` inside
|
||||||
|
`required_scope_for_kind()`. This controls which auth scope a caller
|
||||||
|
needs to submit the event:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
KIND_MY_FEATURE => Ok(Scope::MessagesWrite),
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Handle post-storage side effects** by adding a match arm in
|
||||||
|
`crates/buzz-relay/src/handlers/side_effects.rs` inside
|
||||||
|
`handle_side_effects()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
KIND_MY_FEATURE => handle_my_feature(event, state).await?,
|
||||||
|
```
|
||||||
|
|
||||||
|
`handle_side_effects()` runs after the event is stored — use it for
|
||||||
|
notifications, cache invalidation, or derived data. If the new kind
|
||||||
|
also needs an HTTP bridge surface (for example, a protocol helper that
|
||||||
|
cannot practically use WebSocket), add a handler in
|
||||||
|
`crates/buzz-relay/src/api/` and register it in
|
||||||
|
`crates/buzz-relay/src/router.rs`.
|
||||||
|
|
||||||
|
5. **Persist to the database** — if the event needs to be queryable, add a
|
||||||
|
handler in `buzz-db/src/` (e.g., `buzz-db/src/my_feature.rs`) with
|
||||||
|
the appropriate `INSERT` and `SELECT` queries.
|
||||||
|
|
||||||
|
6. **Index for search** (if applicable) — Postgres FTS indexes persisted
|
||||||
|
events automatically via the `events.search_tsv` generated column. To
|
||||||
|
exclude a privacy-sensitive kind from search, add it to the `CASE WHEN
|
||||||
|
kind IN (...)` exclusion in the `search_tsv` definition (see the initial
|
||||||
|
schema migration) rather than wiring a separate indexer.
|
||||||
|
|
||||||
|
7. **Audit** — the audit log captures all events automatically; no changes
|
||||||
|
needed unless you need custom audit metadata.
|
||||||
|
|
||||||
|
8. **Write tests** — add a unit test for payload serialization in
|
||||||
|
`buzz-core` and an integration test in `buzz-test-client` that sends
|
||||||
|
the new event kind and verifies the expected behavior.
|
||||||
|
|
||||||
|
9. **Document** — `kind.rs` is the authoritative registry of all kind numbers.
|
||||||
|
Update `README.md` if it's a user-facing feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Add a New API Endpoint
|
||||||
|
|
||||||
|
Prefer a signed Nostr event and the existing WebSocket/`POST /events` ingest
|
||||||
|
path over adding endpoint-specific JSON APIs. The relay intentionally exposes
|
||||||
|
only a narrow HTTP surface: NIP-11/NIP-05 metadata, `/events`, `/query`,
|
||||||
|
`/count`, `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and
|
||||||
|
health probes.
|
||||||
|
|
||||||
|
If an HTTP endpoint is still necessary:
|
||||||
|
|
||||||
|
1. **Define the handler** in the appropriate module under
|
||||||
|
`crates/buzz-relay/src/api/`. Resolve the request tenant before any auth or
|
||||||
|
data lookup, use NIP-98 when the endpoint accepts user credentials, and keep
|
||||||
|
community scoping explicit.
|
||||||
|
|
||||||
|
2. **Register the route** in `crates/buzz-relay/src/router.rs` using the
|
||||||
|
narrowest path possible. Do not add new `/api/*` compatibility routes unless
|
||||||
|
the product decision explicitly calls for one.
|
||||||
|
|
||||||
|
3. **Add database queries** in `buzz-db/src/` only when the endpoint cannot be
|
||||||
|
expressed through the existing event query paths.
|
||||||
|
|
||||||
|
4. **Handle errors** using the `api_error()`, `internal_error()`, and
|
||||||
|
`not_found()` helpers in `buzz-relay/src/api/mod.rs`. Return
|
||||||
|
`(StatusCode, Json<Value>)` tuples.
|
||||||
|
|
||||||
|
5. **Write tests** with the `buzz-test-client` harness in
|
||||||
|
`crates/buzz-test-client/tests/`, covering auth, community scoping, and the
|
||||||
|
relevant success path.
|
||||||
|
|
||||||
|
6. **Document** any public endpoint in `ARCHITECTURE.md` and user-facing docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License and CLA
|
||||||
|
|
||||||
|
Buzz is licensed under the **Apache License, Version 2.0**. See
|
||||||
|
[LICENSE](LICENSE) for the full text.
|
||||||
|
|
||||||
|
By submitting a pull request, you agree that your contribution is licensed
|
||||||
|
under the Apache 2.0 license and that you have the right to submit it.
|
||||||
|
|
||||||
|
If your employer has rights to intellectual property you create, you may need
|
||||||
|
their sign-off. When in doubt, check with your legal team.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Thank you for contributing to Buzz. Every bug report, documentation fix,
|
||||||
|
and code contribution makes the project better for everyone. 🐝*
|
||||||
Generated
+11786
File diff suppressed because it is too large
Load Diff
+181
@@ -0,0 +1,181 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/buzz-relay",
|
||||||
|
"crates/buzz-core",
|
||||||
|
"crates/buzz-conformance",
|
||||||
|
"crates/buzz-push-gateway",
|
||||||
|
"crates/buzz-db",
|
||||||
|
"crates/buzz-pubsub",
|
||||||
|
"crates/buzz-auth",
|
||||||
|
"crates/buzz-search",
|
||||||
|
"crates/buzz-audit",
|
||||||
|
"crates/buzz-acp",
|
||||||
|
"crates/buzz-agent",
|
||||||
|
"crates/sprig",
|
||||||
|
"crates/buzz-test-client",
|
||||||
|
"crates/buzz-ws-client",
|
||||||
|
"crates/buzz-admin",
|
||||||
|
"crates/buzz-workflow",
|
||||||
|
"crates/buzz-media",
|
||||||
|
"crates/buzz-cli",
|
||||||
|
"crates/buzz-pairing-cli",
|
||||||
|
"crates/buzz-sdk",
|
||||||
|
"crates/buzz-persona",
|
||||||
|
"crates/git-credential-nostr",
|
||||||
|
"crates/git-sign-nostr",
|
||||||
|
"crates/buzz-pair-relay",
|
||||||
|
"crates/buzz-relay-mesh",
|
||||||
|
"crates/buzz-dev-mcp",
|
||||||
|
"crates/buzz-voice",
|
||||||
|
"crates/buzz-backend-kubernetes",
|
||||||
|
"examples/countdown-bot",
|
||||||
|
]
|
||||||
|
exclude = ["desktop/src-tauri"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.88.0"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/block/sprout"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Runtime
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal", "process"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt", "codec"] }
|
||||||
|
|
||||||
|
# HTTP + WebSocket
|
||||||
|
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||||
|
tower = { version = "0.5", features = ["timeout", "util", "limit"] }
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "timeout", "fs"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { version = "0.9", features = [
|
||||||
|
"runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] }
|
||||||
|
deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] }
|
||||||
|
|
||||||
|
# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the
|
||||||
|
# process-level CryptoProvider is installed explicitly at startup, matching
|
||||||
|
# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the
|
||||||
|
# crate's own rustls dependency.
|
||||||
|
kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] }
|
||||||
|
k8s-openapi = { version = "0.26", features = ["v1_31"] }
|
||||||
|
|
||||||
|
# Nostr
|
||||||
|
nostr = { version = "0.44", features = ["nip44", "nip98"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
postcard = { version = "1", default-features = false, features = ["use-std"] }
|
||||||
|
|
||||||
|
# Inter-relay mesh transport (buzz-relay-mesh)
|
||||||
|
iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
evalexpr = "11"
|
||||||
|
cron = "0.16"
|
||||||
|
# Observability
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
tracing-opentelemetry = { version = "0.33" }
|
||||||
|
opentelemetry = { version = "0.32", features = ["trace"] }
|
||||||
|
opentelemetry_sdk = { version = "0.32", features = ["trace", "rt-tokio"] }
|
||||||
|
opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "grpc-tonic", "tls-ring"] }
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-exporter-prometheus = "0.18"
|
||||||
|
metrics-util = "0.20"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# HTTP client (webhook delivery)
|
||||||
|
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
|
||||||
|
|
||||||
|
# Cryptography
|
||||||
|
sha2 = "0.11"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.13"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Randomness
|
||||||
|
rand = "0.10"
|
||||||
|
subtle = "2.6"
|
||||||
|
zeroize = "1.8"
|
||||||
|
|
||||||
|
# Concurrent data structures
|
||||||
|
dashmap = "6"
|
||||||
|
moka = { version = "0.12", features = ["sync"] }
|
||||||
|
|
||||||
|
# Async stream utilities
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
# WebSocket client (test client)
|
||||||
|
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
# Property-based testing (dev-only)
|
||||||
|
proptest = "1"
|
||||||
|
|
||||||
|
# MCP SDK (used by buzz-dev-mcp and buzz-agent)
|
||||||
|
rmcp = { version = "1.1.0", features = ["server", "transport-io", "macros"] }
|
||||||
|
schemars = { version = "1", default-features = false }
|
||||||
|
|
||||||
|
# Internal crates
|
||||||
|
buzz-core = { path = "crates/buzz-core" }
|
||||||
|
buzz-conformance = { path = "crates/buzz-conformance" }
|
||||||
|
buzz-db = { path = "crates/buzz-db" }
|
||||||
|
buzz-auth = { path = "crates/buzz-auth" }
|
||||||
|
buzz-pubsub = { path = "crates/buzz-pubsub" }
|
||||||
|
buzz-search = { path = "crates/buzz-search" }
|
||||||
|
buzz-audit = { path = "crates/buzz-audit" }
|
||||||
|
buzz-workflow = { path = "crates/buzz-workflow" }
|
||||||
|
buzz-media = { path = "crates/buzz-media" }
|
||||||
|
buzz-sdk = { path = "crates/buzz-sdk" }
|
||||||
|
buzz-ws-client = { path = "crates/buzz-ws-client" }
|
||||||
|
buzz-relay-mesh = { path = "crates/buzz-relay-mesh" }
|
||||||
|
|
||||||
|
# CI profile — builds the relay for desktop e2e. Dependencies keep full
|
||||||
|
# release optimization (warm from main's cache; they carry the runtime hot
|
||||||
|
# path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for
|
||||||
|
# stable e2e timing (PR #307 flakiness was opt-0 + debug-assertions) at
|
||||||
|
# roughly half the codegen cost. `incremental` is irrelevant in CI:
|
||||||
|
# rust-cache exports CARGO_INCREMENTAL=0 and never caches member artifacts.
|
||||||
|
[profile.ci]
|
||||||
|
inherits = "release"
|
||||||
|
lto = false
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.ci.package."*"]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# Sprig profile — optimized for deploy-anywhere Sprig release artifacts.
|
||||||
|
# Sprig is distributed over the network and installed on fresh hosts, so binary
|
||||||
|
# size matters more than compile speed here. Keep this separate from the normal
|
||||||
|
# `release` profile so desktop/dev release builds do not inherit the slower
|
||||||
|
# size-focused settings unless they opt in explicitly.
|
||||||
|
[profile.sprig]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "z"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
# Temporary fork pin: aws-creds 0.39.1 (via rust-s3) cannot read EKS Pod Identity
|
||||||
|
# credentials (AWS_CONTAINER_CREDENTIALS_FULL_URI + AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE),
|
||||||
|
# which the relay pod on bb-block requires for S3 media + git storage. This pins
|
||||||
|
# aws-creds to a fork that adopts the aws-creds portion of durch/rust-s3#449
|
||||||
|
# (FULL_URI + token-file + Authorization header, refresh-safe, with a loopback
|
||||||
|
# allowlist for the auth token). Revert to crates.io once #449 lands upstream.
|
||||||
|
[patch.crates-io]
|
||||||
|
aws-creds = { git = "https://github.com/tlongwell-block/rust-s3", rev = "c9fce3620dd434c1f810101d672cf384268dbb0f" }
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Public Buzz relay image — published as ghcr.io/block/buzz:<tag>.
|
||||||
|
#
|
||||||
|
# Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle
|
||||||
|
# (pnpm + vite), then assembles them into a small debian-slim runtime with
|
||||||
|
# `git` available (the relay shells out to git for repo hydrate / receive-pack
|
||||||
|
# / upload-pack — see crates/buzz-relay/src/api/git).
|
||||||
|
#
|
||||||
|
# Multi-arch is handled by running this same Dockerfile on native amd64 and
|
||||||
|
# native arm64 runners (see .github/workflows/docker.yml). The Dockerfile
|
||||||
|
# itself is platform-agnostic; do not add --platform pins.
|
||||||
|
|
||||||
|
ARG RUST_VERSION=1.95
|
||||||
|
ARG NODE_VERSION=24
|
||||||
|
ARG DEBIAN_VERSION=bookworm
|
||||||
|
|
||||||
|
# Optional extra CA bundle for builds behind a TLS-intercepting corporate proxy
|
||||||
|
# (e.g. a Cloudflare/Zscaler gateway that re-signs TLS). Empty by default, so
|
||||||
|
# public CI builds are unaffected. Point it at a PEM file in the build context:
|
||||||
|
# docker build --build-arg EXTRA_CA_CERTS=path/to/proxy-ca.pem ...
|
||||||
|
# Consumed by the network-touching stages below (cargo + pnpm).
|
||||||
|
ARG EXTRA_CA_CERTS=
|
||||||
|
|
||||||
|
# Optional npm registry for builds where the public registry is unreachable or
|
||||||
|
# policy-blocked (e.g. a corporate mirror / Artifactory). Empty default = public
|
||||||
|
# npmjs, so public CI builds are unaffected. Consumed by the web-builder stage.
|
||||||
|
ARG NPM_REGISTRY=
|
||||||
|
|
||||||
|
# ─── Stage 1: cargo-chef base ───────────────────────────────────────────────
|
||||||
|
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
|
||||||
|
# Trust an optional corporate-proxy CA before any network fetch (no-op if unset).
|
||||||
|
ARG EXTRA_CA_CERTS
|
||||||
|
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
|
||||||
|
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
|
||||||
|
cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
|
||||||
|
&& update-ca-certificates \
|
||||||
|
&& echo "CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> /etc/environment; \
|
||||||
|
fi
|
||||||
|
ENV CARGO_HTTP_CAINFO=/etc/ssl/certs/ca-certificates.crt
|
||||||
|
RUN cargo install cargo-chef --locked --version 0.1.71
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# ─── Stage 2: plan dependency graph ─────────────────────────────────────────
|
||||||
|
# Only the manifests are needed to compute the recipe; this layer rebuilds
|
||||||
|
# only when Cargo.{toml,lock} or crate manifests change, not on every source
|
||||||
|
# edit.
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
# ─── Stage 3: cook dependencies, then build the binary ──────────────────────
|
||||||
|
FROM chef AS builder
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# Keep enough DWARF for native profilers to resolve optimized code to source
|
||||||
|
# locations. The normal runtime strips it below; runtime-debug retains it.
|
||||||
|
ENV CARGO_PROFILE_RELEASE_DEBUG=line-tables-only
|
||||||
|
COPY --from=planner /build/recipe.json recipe.json
|
||||||
|
# Cook the full workspace recipe — relay deps include workspace siblings, so
|
||||||
|
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
|
||||||
|
-p buzz-admin --bin buzz-admin \
|
||||||
|
-p buzz-pair-relay --bin buzz-pair-relay
|
||||||
|
|
||||||
|
# Derive the normal release binaries from the same optimized ELF files as the
|
||||||
|
# debug image so the two variants cannot drift at code-generation time.
|
||||||
|
FROM builder AS stripped-binaries
|
||||||
|
RUN strip target/release/buzz-relay \
|
||||||
|
&& strip target/release/buzz-admin \
|
||||||
|
&& strip target/release/buzz-pair-relay
|
||||||
|
|
||||||
|
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
|
||||||
|
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
|
||||||
|
# vice versa.
|
||||||
|
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder
|
||||||
|
WORKDIR /build
|
||||||
|
# Trust an optional corporate-proxy CA so corepack + pnpm can fetch over an
|
||||||
|
# intercepting TLS gateway (no-op if EXTRA_CA_CERTS is unset).
|
||||||
|
ARG EXTRA_CA_CERTS
|
||||||
|
COPY --chmod=0644 ${EXTRA_CA_CERTS:-Dockerfile} /tmp/extra-ca/src
|
||||||
|
RUN if [ -n "${EXTRA_CA_CERTS}" ]; then \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& cp /tmp/extra-ca/src /usr/local/share/ca-certificates/extra-proxy-ca.crt \
|
||||||
|
&& update-ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi
|
||||||
|
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||||
|
# Point npm + corepack at an optional mirror (no-op when NPM_REGISTRY is unset).
|
||||||
|
# corepack reads COREPACK_NPM_REGISTRY to fetch the pinned pnpm; pnpm/npm read
|
||||||
|
# the .npmrc registry for dependency installs.
|
||||||
|
ARG NPM_REGISTRY
|
||||||
|
ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
|
||||||
|
# When using a mirror, disable corepack's npmjs signature check: the mirror
|
||||||
|
# republishes tarballs without the public registry's provenance signatures, so
|
||||||
|
# strict verification fails ("No compatible signature found"). Only relaxed on
|
||||||
|
# the mirror path — public builds (NPM_REGISTRY unset) keep strict verification.
|
||||||
|
RUN if [ -n "${NPM_REGISTRY}" ]; then \
|
||||||
|
echo "registry=${NPM_REGISTRY}" > /build/.npmrc \
|
||||||
|
&& echo "COREPACK_INTEGRITY_KEYS=0" >> /etc/environment; \
|
||||||
|
fi
|
||||||
|
ENV COREPACK_INTEGRITY_KEYS=${NPM_REGISTRY:+0}
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY patches/ patches/
|
||||||
|
COPY web/package.json web/
|
||||||
|
COPY admin-web/package.json admin-web/
|
||||||
|
RUN pnpm install --frozen-lockfile --filter buzz-web --filter buzz-admin-web
|
||||||
|
COPY web/ web/
|
||||||
|
COPY admin-web/ admin-web/
|
||||||
|
RUN pnpm -C web build && pnpm -C admin-web build
|
||||||
|
|
||||||
|
# ─── Stage 5: shared runtime ────────────────────────────────────────────────
|
||||||
|
FROM debian:${DEBIAN_VERSION}-slim AS runtime-base
|
||||||
|
|
||||||
|
# OCI annotations: required for GHCR to auto-link the image to this repo and
|
||||||
|
# inherit its visibility. org.opencontainers.image.source is the load-bearing
|
||||||
|
# one — without it GHCR keeps the image private even when the repo is public.
|
||||||
|
LABEL org.opencontainers.image.title="Buzz" \
|
||||||
|
org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \
|
||||||
|
org.opencontainers.image.source="https://github.com/block/buzz" \
|
||||||
|
org.opencontainers.image.url="https://github.com/block/buzz" \
|
||||||
|
org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \
|
||||||
|
org.opencontainers.image.licenses="Apache-2.0"
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system --gid 1000 buzz \
|
||||||
|
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \
|
||||||
|
--create-home --shell /usr/sbin/nologin buzz
|
||||||
|
|
||||||
|
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
||||||
|
COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web
|
||||||
|
|
||||||
|
# The invite landing page is always served from the bundled web UI. Repository
|
||||||
|
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. The
|
||||||
|
# admin bundle is inert until BUZZ_ADMIN_HOST is configured.
|
||||||
|
ENV BUZZ_WEB_DIR=/srv/buzz/web \
|
||||||
|
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||||||
|
|
||||||
|
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
|
||||||
|
EXPOSE 3000 8080 9102
|
||||||
|
|
||||||
|
# deploy/compose mounts a volume here; pre-created so it inherits buzz:buzz.
|
||||||
|
RUN mkdir -p /data/git && chown buzz:buzz /data/git
|
||||||
|
|
||||||
|
USER buzz:buzz
|
||||||
|
WORKDIR /var/lib/buzz
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/buzz-relay"]
|
||||||
|
|
||||||
|
# Optimized binaries with line-table debug information for native profiling.
|
||||||
|
# Published under debug-* tags; runtime behavior otherwise matches the normal
|
||||||
|
# image exactly.
|
||||||
|
FROM runtime-base AS runtime-debug
|
||||||
|
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
|
||||||
|
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
|
||||||
|
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||||
|
|
||||||
|
# Keep the stripped runtime as the final/default Dockerfile target so existing
|
||||||
|
# `docker build .` callers and release tags retain their current behavior.
|
||||||
|
FROM runtime-base AS runtime
|
||||||
|
COPY --from=stripped-binaries /build/target/release/buzz-relay /usr/local/bin/buzz-relay
|
||||||
|
COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin
|
||||||
|
COPY --from=stripped-binaries /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
ARG RUST_VERSION=1.95
|
||||||
|
ARG DEBIAN_VERSION=bookworm
|
||||||
|
|
||||||
|
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
|
||||||
|
RUN cargo install cargo-chef --locked --version 0.1.71
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=planner /build/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway \
|
||||||
|
&& strip target/release/buzz-push-gateway
|
||||||
|
|
||||||
|
FROM debian:${DEBIAN_VERSION}-slim AS runtime
|
||||||
|
LABEL org.opencontainers.image.title="Buzz Push Gateway" \
|
||||||
|
org.opencontainers.image.description="Capability-gated APNs last hop for Buzz" \
|
||||||
|
org.opencontainers.image.source="https://github.com/block/buzz" \
|
||||||
|
org.opencontainers.image.licenses="Apache-2.0"
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system --gid 1000 buzz \
|
||||||
|
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz --create-home --shell /usr/sbin/nologin buzz
|
||||||
|
COPY --from=builder /build/target/release/buzz-push-gateway /usr/local/bin/buzz-push-gateway
|
||||||
|
EXPOSE 8080 8081
|
||||||
|
USER buzz:buzz
|
||||||
|
WORKDIR /var/lib/buzz
|
||||||
|
ENTRYPOINT ["/usr/local/bin/buzz-push-gateway"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Multi-arch is produced by building this file on native amd64 and arm64 runners.
|
||||||
|
# Keep both bases pinned to manifest-list digests so either architecture resolves
|
||||||
|
# to immutable source bytes.
|
||||||
|
FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
cmake \
|
||||||
|
git \
|
||||||
|
musl-dev \
|
||||||
|
openssl-dev \
|
||||||
|
openssl-libs-static \
|
||||||
|
perl \
|
||||||
|
pkgconf \
|
||||||
|
protoc
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --locked --profile sprig -p sprig \
|
||||||
|
&& strip target/sprig/sprig
|
||||||
|
|
||||||
|
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash ca-certificates curl git \
|
||||||
|
&& adduser -D -h /home/agent agent \
|
||||||
|
&& install -d -o agent -g agent /workspace /home/agent \
|
||||||
|
&& git config --system gpg.format x509 \
|
||||||
|
&& git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \
|
||||||
|
&& git config --system commit.gpgSign true \
|
||||||
|
&& git config --system tag.gpgSign true
|
||||||
|
|
||||||
|
COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig
|
||||||
|
COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint
|
||||||
|
RUN for name in \
|
||||||
|
buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \
|
||||||
|
git-credential-nostr git-sign-nostr; do \
|
||||||
|
ln -s sprig "/usr/local/bin/$name"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
ENV HOME=/home/agent \
|
||||||
|
PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
WORKDIR /home/agent
|
||||||
|
USER agent
|
||||||
|
ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md)
|
||||||
@@ -0,0 +1,979 @@
|
|||||||
|
# Buzz — development task runner
|
||||||
|
|
||||||
|
set dotenv-load := true
|
||||||
|
|
||||||
|
desktop_dir := "desktop"
|
||||||
|
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
|
||||||
|
web_dir := "web"
|
||||||
|
|
||||||
|
# Opt-in mesh-llm. Off by default so `just dev`/`just staging`/`just production`
|
||||||
|
# skip ~420 extra crates + the llama.cpp native runtime build and stay fast to
|
||||||
|
# iterate on. Turn on to test mesh compute features: `just mesh=1 dev` /
|
||||||
|
# `just mesh=1 staging` / `just mesh=1 production`.
|
||||||
|
mesh := ""
|
||||||
|
|
||||||
|
# Reset only the current standalone desktop instance before launch.
|
||||||
|
# Usage: `just fresh=1 desktop-standalone`.
|
||||||
|
fresh := ""
|
||||||
|
|
||||||
|
# List all available tasks
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# ─── Dev Environment ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Install required dev tools via Hermit and create .env (safe to re-run)
|
||||||
|
bootstrap:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
# Hermit's bin/ symlinks auto-download pinned tool versions on first use.
|
||||||
|
# Running each tool once triggers the download if not already cached.
|
||||||
|
echo "Ensuring toolchain via Hermit..."
|
||||||
|
cargo --version &
|
||||||
|
node --version &
|
||||||
|
pnpm --version &
|
||||||
|
wait
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
echo "Error: Docker is required but not installed."
|
||||||
|
echo "Install it from https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created .env from .env.example — review it before running just dev."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Docker services, run migrations, install desktop deps
|
||||||
|
setup: bootstrap
|
||||||
|
./scripts/dev-setup.sh
|
||||||
|
|
||||||
|
# Install git hooks via lefthook (dispatches from the shared .git/hooks dir so all
|
||||||
|
# linked worktrees inherit the same hooks without a worktree-relative .hooks path)
|
||||||
|
hooks:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# Use the Hermit-pinned lefthook (bin/lefthook self-downloads on first use):
|
||||||
|
# works with no pre-installed lefthook and guarantees the pinned version
|
||||||
|
# rather than whatever happens to be on PATH.
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
# --path-format=absolute guarantees an absolute path from every invocation context:
|
||||||
|
# without it, --git-common-dir returns ".git" from the main checkout and a
|
||||||
|
# relative hooksPath would break linked-worktree dispatch just like .hooks did.
|
||||||
|
HOOKS_DIR="$(git rev-parse --path-format=absolute --git-common-dir)/hooks"
|
||||||
|
git config --local core.hooksPath "$HOOKS_DIR"
|
||||||
|
lefthook install --force
|
||||||
|
|
||||||
|
# Wipe development state and recreate a clean environment. Installed Buzz is preserved.
|
||||||
|
[confirm("This will DELETE all development data and preserve installed Buzz. Continue? (y/N)")]
|
||||||
|
reset:
|
||||||
|
./scripts/dev-reset.sh --yes
|
||||||
|
|
||||||
|
# Stop all dev services (keep data)
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Show dev service status
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Tail all service logs
|
||||||
|
logs *ARGS:
|
||||||
|
docker compose logs -f {{ARGS}}
|
||||||
|
|
||||||
|
# ─── Build & Check ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Build the Rust workspace
|
||||||
|
build:
|
||||||
|
cargo build --workspace
|
||||||
|
|
||||||
|
# Build the Rust workspace in release mode
|
||||||
|
build-release:
|
||||||
|
cargo build --workspace --release
|
||||||
|
|
||||||
|
# Run repo lint and formatting checks
|
||||||
|
check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check
|
||||||
|
|
||||||
|
# Format all Rust code
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Check formatting without modifying files
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
# Run clippy with warnings as errors
|
||||||
|
clippy:
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# Install JS dependencies (pnpm workspace — installs all packages from root)
|
||||||
|
desktop-install:
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Install JS dependencies reproducibly for CI (pnpm workspace)
|
||||||
|
desktop-install-ci:
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Run desktop lint and format checks
|
||||||
|
desktop-check:
|
||||||
|
cd {{desktop_dir}} && pnpm check
|
||||||
|
|
||||||
|
# Fix desktop lint and format issues
|
||||||
|
desktop-fix:
|
||||||
|
cd {{desktop_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
|
||||||
|
|
||||||
|
# Run desktop TS helper unit tests
|
||||||
|
desktop-test:
|
||||||
|
cd {{desktop_dir}} && pnpm test
|
||||||
|
|
||||||
|
# Run desktop TypeScript checks
|
||||||
|
desktop-typecheck:
|
||||||
|
cd {{desktop_dir}} && pnpm typecheck
|
||||||
|
|
||||||
|
# Build desktop frontend assets
|
||||||
|
desktop-build:
|
||||||
|
cd {{desktop_dir}} && pnpm build
|
||||||
|
|
||||||
|
# Format desktop Tauri Rust code
|
||||||
|
desktop-tauri-fmt:
|
||||||
|
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all
|
||||||
|
|
||||||
|
# Check desktop Tauri Rust formatting
|
||||||
|
desktop-tauri-fmt-check:
|
||||||
|
cargo fmt --manifest-path {{desktop_tauri_manifest}} --all -- --check
|
||||||
|
|
||||||
|
# Format all code (Rust + Tauri Rust + Dart)
|
||||||
|
fmt-all: fmt desktop-tauri-fmt mobile-fmt
|
||||||
|
|
||||||
|
# Fix all formatting and lint issues
|
||||||
|
fix-all: fmt desktop-tauri-fmt desktop-fix web-fix mobile-fix
|
||||||
|
|
||||||
|
# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
|
||||||
|
# Sidecar binary list must stay in sync with desktop-release-build below.
|
||||||
|
_ensure-sidecar-stubs:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||||
|
mkdir -p desktop/src-tauri/binaries
|
||||||
|
SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz)
|
||||||
|
if [[ "$TARGET" != *windows* ]]; then
|
||||||
|
SIDECARS+=(buzz-backend-kubernetes)
|
||||||
|
fi
|
||||||
|
for bin in "${SIDECARS[@]}"; do
|
||||||
|
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ensure Docker dev services (Postgres, Redis, etc.) are running and healthy
|
||||||
|
_ensure-services:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
|
||||||
|
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
|
||||||
|
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
|
||||||
|
echo "Services already healthy"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Starting services..."
|
||||||
|
docker compose up -d || true
|
||||||
|
echo -n "Waiting for services"
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
pg=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-postgres 2>/dev/null || echo "not_found")
|
||||||
|
redis=$(docker inspect --format '{{"{{"}}.State.Health.Status{{"}}"}}' buzz-redis 2>/dev/null || echo "not_found")
|
||||||
|
if [[ "$pg" == "healthy" && "$redis" == "healthy" ]]; then
|
||||||
|
echo " ready"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo " timed out"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# Apply database migrations and seed the local dev community if the dev database is running
|
||||||
|
_ensure-migrations: _ensure-services
|
||||||
|
cargo run -p buzz-admin -- migrate
|
||||||
|
./scripts/seed-local-community.sh
|
||||||
|
|
||||||
|
# Run clippy on the desktop Tauri Rust crate
|
||||||
|
desktop-tauri-clippy: _ensure-sidecar-stubs
|
||||||
|
cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# Check the desktop Tauri Rust crate compiles
|
||||||
|
desktop-tauri-check: _ensure-sidecar-stubs
|
||||||
|
cargo check --manifest-path {{desktop_tauri_manifest}}
|
||||||
|
|
||||||
|
# Run desktop Tauri Rust unit tests
|
||||||
|
desktop-tauri-test: _ensure-sidecar-stubs
|
||||||
|
cd desktop/src-tauri && cargo test --workspace
|
||||||
|
|
||||||
|
# Run the native terminal latency gate explicitly on a known-idle host.
|
||||||
|
# This is intentionally excluded from shared CI: scheduler contention makes a
|
||||||
|
# wall-clock assertion flaky, and the release profile is the shipped shape.
|
||||||
|
desktop-terminal-performance-test:
|
||||||
|
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
# Verify compiled-flag behavior under both compile states (clean + capability set).
|
||||||
|
# Runs the auto-connect and owner-only access focused tests twice with
|
||||||
|
# independently supplied expected values; build.rs rerun-if-env-changed
|
||||||
|
# triggers recompilation.
|
||||||
|
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd desktop/src-tauri
|
||||||
|
echo "=== Clean build (no flag) → expect false ==="
|
||||||
|
env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
|
||||||
|
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \
|
||||||
|
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
||||||
|
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
||||||
|
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
||||||
|
cargo test --lib
|
||||||
|
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
||||||
|
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
||||||
|
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
||||||
|
echo "=== Internal build (flags set) → expect true ==="
|
||||||
|
BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \
|
||||||
|
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \
|
||||||
|
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
||||||
|
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
||||||
|
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
||||||
|
cargo test --lib
|
||||||
|
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
||||||
|
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
||||||
|
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
||||||
|
echo "Both compiled states verified."
|
||||||
|
|
||||||
|
# Build the full desktop Tauri app locally (unsigned, for testing)
|
||||||
|
# Sidecar binary list must stay in sync with _ensure-sidecar-stubs above.
|
||||||
|
# pnpm install is unconditional here: release builds must start from a clean dep tree.
|
||||||
|
desktop-release-build target="aarch64-apple-darwin":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
TARGET={{target}}
|
||||||
|
mkdir -p desktop/src-tauri/binaries
|
||||||
|
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
|
||||||
|
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
|
||||||
|
if [[ "$TARGET" != *windows* ]]; then
|
||||||
|
touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET"
|
||||||
|
fi
|
||||||
|
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
|
||||||
|
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||||
|
touch "desktop/src-tauri/binaries/buzz-$TARGET"
|
||||||
|
pnpm install
|
||||||
|
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
|
||||||
|
|
||||||
|
# Run desktop checks suitable for CI / pre-push
|
||||||
|
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
|
||||||
|
|
||||||
|
# Seed deterministic channel data for desktop Playwright tests
|
||||||
|
desktop-e2e-seed: _ensure-migrations
|
||||||
|
./scripts/setup-desktop-test-data.sh
|
||||||
|
|
||||||
|
# Run desktop browser smoke tests
|
||||||
|
desktop-e2e-smoke:
|
||||||
|
cd {{desktop_dir}} && pnpm test:e2e:smoke
|
||||||
|
|
||||||
|
# Run desktop relay-backed e2e tests
|
||||||
|
desktop-e2e-integration: _ensure-migrations
|
||||||
|
cd {{desktop_dir}} && pnpm test:e2e:integration
|
||||||
|
|
||||||
|
# Run only the e2e specs changed vs origin/main (both projects) before pushing
|
||||||
|
desktop-e2e-pre-push: _ensure-migrations
|
||||||
|
git fetch origin main
|
||||||
|
cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main
|
||||||
|
|
||||||
|
# Run all checks suitable for CI / pre-push (no infra needed)
|
||||||
|
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
|
||||||
|
|
||||||
|
# ─── Test ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run all tests (unit + integration)
|
||||||
|
test:
|
||||||
|
./scripts/run-tests.sh all
|
||||||
|
|
||||||
|
# Run unit tests only (no infra needed)
|
||||||
|
test-unit:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if command -v cargo-nextest &>/dev/null; then
|
||||||
|
cargo nextest run -p buzz-core -p buzz-auth --lib
|
||||||
|
cargo nextest run -p buzz-voice --lib
|
||||||
|
cargo nextest run -p buzz-cli
|
||||||
|
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
|
||||||
|
# They guard the embedded-migrator invariant (exactly the consolidated
|
||||||
|
# 0001; cutover/backfill stays an operator script, not startup state)
|
||||||
|
# and the tenant-scoping lints. The Postgres-backed buzz-db tests are
|
||||||
|
# #[ignore]d, so --lib runs only the infra-free set. Without this gate a
|
||||||
|
# stray file in migrations/ or a broken lint ships green.
|
||||||
|
cargo nextest run -p buzz-db --lib
|
||||||
|
# Multi-tenant conformance gate (buzz-conformance): the independent
|
||||||
|
# replay checker + golden fixtures. No infra — pure in-process trace
|
||||||
|
# replay — so it belongs in the unit job. Run all targets (lib + the
|
||||||
|
# tests/replay_fixtures.rs integration test), not just --lib.
|
||||||
|
cargo nextest run -p buzz-conformance
|
||||||
|
# Gateway unit and black-box HTTP tests are infra-free. Postgres-backed
|
||||||
|
# contract/race tests run in the dedicated CI job below.
|
||||||
|
cargo nextest run -p buzz-push-gateway
|
||||||
|
# Kubernetes backend provider: the decision layers (state machine, GC
|
||||||
|
# planner, env precedence, naming, wire) are pure functions with a fake
|
||||||
|
# substrate, so they belong in the unit job. Enumerated explicitly
|
||||||
|
# because nothing in CI runs `cargo test --workspace` — workspace
|
||||||
|
# membership alone buys clippy/check, not a single executed test.
|
||||||
|
cargo nextest run -p buzz-backend-kubernetes
|
||||||
|
else
|
||||||
|
./scripts/run-tests.sh unit
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run integration tests only (starts services if needed)
|
||||||
|
test-integration:
|
||||||
|
./scripts/run-tests.sh integration
|
||||||
|
|
||||||
|
# Buzz shared compute e2e: current desktop discovery/admission logic and
|
||||||
|
# Playwright UI coverage.
|
||||||
|
mesh-e2e:
|
||||||
|
cargo test --manifest-path {{desktop_dir}}/src-tauri/Cargo.toml --features mesh-llm mesh_llm --lib
|
||||||
|
cd {{desktop_dir}} && pnpm test:e2e:smoke -- mesh-compute.spec.ts
|
||||||
|
|
||||||
|
# Reset only development state, seed deterministic local channels, and launch
|
||||||
|
# the mesh-enabled desktop with the repository's public Tyler test identity.
|
||||||
|
# This is for local verification only; never point this identity at staging/prod.
|
||||||
|
[confirm("This will reset development data, preserve installed Buzz, then launch a seeded mesh dev app. Continue? (y/N)")]
|
||||||
|
mesh-dev-fresh:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
./scripts/dev-reset.sh --yes
|
||||||
|
./scripts/setup-desktop-test-data.sh
|
||||||
|
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
|
||||||
|
export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
|
||||||
|
export BUZZ_ALLOW_NIP_OA_AUTH=true
|
||||||
|
export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
|
||||||
|
export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
export BUZZ_RECONCILE_CHANNELS=true
|
||||||
|
export BUZZ_RESET_WEBVIEW_STATE=1
|
||||||
|
exec just mesh=1 dev
|
||||||
|
|
||||||
|
# Real serve->client->inference on this machine (not CI).
|
||||||
|
mesh-e2e-hardware:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
cargo run -p buzz-relay --example mesh_serve_client_smoke
|
||||||
|
|
||||||
|
# Three isolated node processes: trusted member joins and infers; stranger is rejected.
|
||||||
|
# Uses temp homes and explicit mesh owner keystores. Never reads the Buzz Keychain.
|
||||||
|
mesh-e2e-admission:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
cargo run -p buzz-relay --example mesh_admission_smoke
|
||||||
|
|
||||||
|
# Full hardware confidence suite: routing, owner admission, and real agent inference.
|
||||||
|
mesh-e2e-confidence:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
cargo build --release -p buzz-agent -p buzz-dev-mcp
|
||||||
|
cargo run -p buzz-relay --example mesh_serve_client_smoke
|
||||||
|
cargo run -p buzz-relay --example mesh_admission_smoke
|
||||||
|
cargo run -p buzz-relay --example mesh_agent_e2e
|
||||||
|
|
||||||
|
# Take desktop screenshots using the mock bridge
|
||||||
|
desktop-screenshot *ARGS:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm -C {{desktop_dir}} build:e2e
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
if ! curl -sf http://127.0.0.1:4173/ >/dev/null 2>&1; then
|
||||||
|
python3 -m http.server 4173 -d dist >/dev/null 2>&1 &
|
||||||
|
trap "kill $! 2>/dev/null || true" EXIT
|
||||||
|
for i in $(seq 1 20); do curl -sf http://127.0.0.1:4173/ >/dev/null && break; sleep 0.5; done
|
||||||
|
fi
|
||||||
|
node tests/helpers/screenshot.mjs {{ARGS}}
|
||||||
|
|
||||||
|
# ─── Run ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Start the relay server (auto-starts Docker services if needed)
|
||||||
|
relay: bootstrap _ensure-migrations
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
cargo run -p buzz-relay
|
||||||
|
|
||||||
|
# Start the relay with the built web UI served from it
|
||||||
|
relay-web: bootstrap _ensure-migrations
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
pnpm -C web build
|
||||||
|
BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay
|
||||||
|
|
||||||
|
# Build and run the private read-only admin dashboard
|
||||||
|
admin: bootstrap _ensure-migrations
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
pnpm -C admin-web build
|
||||||
|
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
|
||||||
|
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
|
||||||
|
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
|
||||||
|
cargo run -p buzz-relay
|
||||||
|
|
||||||
|
# Seed deterministic reports and product feedback for local admin dashboard review
|
||||||
|
admin-seed: _ensure-migrations
|
||||||
|
./scripts/seed-admin-dashboard.sh
|
||||||
|
|
||||||
|
# Run focused relay and browser checks for the read-only admin dashboard
|
||||||
|
admin-check: fmt-check
|
||||||
|
cargo check -p buzz-relay --all-targets
|
||||||
|
cargo test -p buzz-relay api::admin
|
||||||
|
cargo test -p buzz-relay router::tests
|
||||||
|
pnpm -C admin-web check
|
||||||
|
pnpm -C admin-web exec playwright test
|
||||||
|
|
||||||
|
# Start the relay server in release mode
|
||||||
|
relay-release: _ensure-migrations
|
||||||
|
cargo run -p buzz-relay --release
|
||||||
|
|
||||||
|
|
||||||
|
# Run the desktop Tauri app in dev mode with a local relay (ports and identity derived from worktree)
|
||||||
|
dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
|
||||||
|
relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
|
||||||
|
health_port="${BUZZ_HEALTH_PORT:-8080}"
|
||||||
|
metrics_port="${BUZZ_METRICS_PORT:-9102}"
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do
|
||||||
|
name="${spec%%:*}"; port="${spec##*:}"
|
||||||
|
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2
|
||||||
|
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
|
||||||
|
echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
|
||||||
|
if [[ -n "{{mesh}}" ]]; then
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
fi
|
||||||
|
# Docker Desktop's forwarded MinIO port can stall under the deployment
|
||||||
|
# probe's 32 concurrent writers. Keep the gate enabled in local dev, using
|
||||||
|
# the bounded profile already used by the relay test launcher.
|
||||||
|
export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}"
|
||||||
|
export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}"
|
||||||
|
./target/debug/buzz-relay &
|
||||||
|
RELAY_PID=$!
|
||||||
|
cleanup() {
|
||||||
|
[[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true
|
||||||
|
kill "$RELAY_PID" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
relay_ready=false
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if ! kill -0 "$RELAY_PID" 2>/dev/null; then
|
||||||
|
echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2
|
||||||
|
wait "$RELAY_PID" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then
|
||||||
|
relay_ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
if [[ "$relay_ready" != true ]]; then
|
||||||
|
echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
source ../scripts/instance-env.sh
|
||||||
|
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||||
|
echo "Starting on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||||
|
FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
|
||||||
|
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||||
|
|
||||||
|
# Run only the desktop app. No relay, database, Docker, migrations, or .env are needed.
|
||||||
|
# The app opens normally and asks for a community before making a relay connection.
|
||||||
|
desktop-standalone *ARGS: _ensure-sidecar-stubs
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
|
||||||
|
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||||
|
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
|
||||||
|
for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do
|
||||||
|
cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
done
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY
|
||||||
|
if [[ -n "{{fresh}}" ]]; then
|
||||||
|
export BUZZ_RESET_WEBVIEW_STATE=1
|
||||||
|
fi
|
||||||
|
source ../scripts/instance-env.sh
|
||||||
|
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||||
|
export BUZZ_DEV_KEYRING_SERVICE="buzz-desktop-dev.${BUZZ_INSTANCE_SLUG:-main}"
|
||||||
|
if [[ -n "{{fresh}}" ]]; then
|
||||||
|
../scripts/reset-desktop-standalone-state.sh "$INSTANCE_ID" "$BUZZ_DEV_KEYRING_SERVICE"
|
||||||
|
fi
|
||||||
|
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
|
||||||
|
echo "Starting standalone desktop on Vite port ${BUZZ_VITE_PORT}; no relay services were started"
|
||||||
|
pnpm exec tauri dev --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||||
|
|
||||||
|
# Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
|
||||||
|
staging *ARGS: bootstrap _ensure-sidecar-stubs
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
pnpm install # unconditional: staging must always start with a clean dep tree
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
|
||||||
|
FEATURES=()
|
||||||
|
if [[ -n "{{mesh}}" ]]; then
|
||||||
|
FEATURES=(--features mesh-llm)
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
fi
|
||||||
|
# Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
|
||||||
|
# buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
|
||||||
|
# exe dir for executable buzz-backend-* files, so the non-executable stub that
|
||||||
|
# tauri dev copies next to the exe would hide the provider from "Run on".
|
||||||
|
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||||
|
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
|
||||||
|
STAGING_SIDECARS=(buzz)
|
||||||
|
if [[ "$TARGET" != *windows* ]]; then
|
||||||
|
STAGING_SIDECARS+=(buzz-backend-kubernetes)
|
||||||
|
fi
|
||||||
|
for bin in "${STAGING_SIDECARS[@]}"; do
|
||||||
|
cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
done
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
|
||||||
|
source ../scripts/instance-env.sh
|
||||||
|
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
|
||||||
|
# agent workers. Reap this instance's agents on exit as a backstop.
|
||||||
|
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||||
|
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
|
||||||
|
echo "Starting staging on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||||
|
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||||
|
|
||||||
|
# Run the desktop app against the production relay (installs deps + builds agent tools automatically)
|
||||||
|
production *ARGS: bootstrap _ensure-sidecar-stubs
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
pnpm install # unconditional: production must always start with a clean dep tree
|
||||||
|
cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
|
||||||
|
FEATURES=()
|
||||||
|
if [[ -n "{{mesh}}" ]]; then
|
||||||
|
FEATURES=(--features mesh-llm)
|
||||||
|
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
|
||||||
|
fi
|
||||||
|
# Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up.
|
||||||
|
# buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the
|
||||||
|
# exe dir for executable buzz-backend-* files, so the non-executable stub that
|
||||||
|
# tauri dev copies next to the exe would hide the provider from "Run on".
|
||||||
|
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||||
|
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
|
||||||
|
PRODUCTION_SIDECARS=(buzz)
|
||||||
|
if [[ "$TARGET" != *windows* ]]; then
|
||||||
|
PRODUCTION_SIDECARS+=(buzz-backend-kubernetes)
|
||||||
|
fi
|
||||||
|
for bin in "${PRODUCTION_SIDECARS[@]}"; do
|
||||||
|
cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||||
|
done
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz"
|
||||||
|
source ../scripts/instance-env.sh
|
||||||
|
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
|
||||||
|
# agent workers. Reap this instance's agents on exit as a backstop.
|
||||||
|
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||||
|
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true' EXIT
|
||||||
|
echo "Starting production on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||||
|
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||||
|
|
||||||
|
# Run the desktop frontend dev server (port derived from worktree)
|
||||||
|
desktop-dev:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd {{desktop_dir}}
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
source ../scripts/instance-env.sh
|
||||||
|
echo "Starting frontend dev server on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||||
|
pnpm exec vite --port "${BUZZ_VITE_PORT}" --strictPort
|
||||||
|
|
||||||
|
# ─── Web ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run the web frontend dev server (port derived from worktree to avoid collisions)
|
||||||
|
web:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
[[ -d node_modules ]] || pnpm install
|
||||||
|
source scripts/instance-env.sh
|
||||||
|
export VITE_PORT=$((BUZZ_VITE_PORT + 100))
|
||||||
|
export VITE_RELAY_URL="${BUZZ_RELAY_URL}"
|
||||||
|
echo "Starting web dev server on port ${VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||||
|
cd {{web_dir}}
|
||||||
|
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
||||||
|
|
||||||
|
# Run web lint and format checks
|
||||||
|
web-check:
|
||||||
|
cd {{web_dir}} && pnpm check
|
||||||
|
|
||||||
|
# Fix web lint and format issues
|
||||||
|
web-fix:
|
||||||
|
cd {{web_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
|
||||||
|
|
||||||
|
# Run web TypeScript checks
|
||||||
|
web-typecheck:
|
||||||
|
cd {{web_dir}} && pnpm typecheck
|
||||||
|
|
||||||
|
# Build web frontend assets
|
||||||
|
web-build:
|
||||||
|
cd {{web_dir}} && pnpm build
|
||||||
|
|
||||||
|
# Run web browser smoke tests
|
||||||
|
web-e2e-smoke:
|
||||||
|
cd {{web_dir}} && pnpm test:e2e:smoke
|
||||||
|
|
||||||
|
# ─── Mobile ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
mobile_dir := "mobile"
|
||||||
|
|
||||||
|
# Install mobile Flutter dependencies
|
||||||
|
mobile-install:
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter pub get
|
||||||
|
|
||||||
|
# Format all Dart code
|
||||||
|
mobile-fmt:
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format .
|
||||||
|
|
||||||
|
# Fix mobile formatting and run analysis
|
||||||
|
mobile-fix:
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format . && flutter analyze
|
||||||
|
|
||||||
|
# Run mobile lint and format checks
|
||||||
|
mobile-check:
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && node ./scripts/check-l10n.mjs && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs
|
||||||
|
|
||||||
|
# Run mobile tests
|
||||||
|
mobile-test:
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test
|
||||||
|
|
||||||
|
# Regenerate the emoji dataset asset from desktop's emoji-mart install.
|
||||||
|
# Output is committed — rerun after bumping @emoji-mart/data.
|
||||||
|
mobile-emoji-data:
|
||||||
|
node {{mobile_dir}}/scripts/generate-emoji-data.mjs
|
||||||
|
|
||||||
|
# Compile an unsigned Android debug APK (worktree-aware debug identity)
|
||||||
|
mobile-build-android:
|
||||||
|
./scripts/mobile-worktree-overrides.sh
|
||||||
|
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub
|
||||||
|
|
||||||
|
# Run the mobile app on iOS simulator (worktree-aware debug identity)
|
||||||
|
mobile-dev:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if ! pgrep -x Simulator &>/dev/null; then
|
||||||
|
open -a Simulator
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
./scripts/mobile-worktree-overrides.sh
|
||||||
|
cd {{mobile_dir}}
|
||||||
|
unset GIT_DIR GIT_WORK_TREE
|
||||||
|
flutter run
|
||||||
|
|
||||||
|
# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept)
|
||||||
|
mobile-clean:
|
||||||
|
./scripts/mobile-worktree-clean.sh
|
||||||
|
|
||||||
|
# ─── Database ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Apply database migrations
|
||||||
|
migrate: _ensure-migrations
|
||||||
|
|
||||||
|
# ─── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Remove build artifacts
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
cargo clean --manifest-path desktop/src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
# Check the Rust workspace compiles without producing binaries
|
||||||
|
check-compile:
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
|
||||||
|
# ─── Release ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Read the current desktop version from package.json
|
||||||
|
get-current-version:
|
||||||
|
@node -p "require('./desktop/package.json').version"
|
||||||
|
|
||||||
|
# Read the current relay version from its crate manifest
|
||||||
|
get-current-relay-version:
|
||||||
|
@grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/'
|
||||||
|
|
||||||
|
# Compute next minor version (e.g., 0.3.0 → 0.4.0)
|
||||||
|
get-next-minor-version:
|
||||||
|
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')"
|
||||||
|
|
||||||
|
# Compute next patch version (e.g., 0.3.0 → 0.3.1)
|
||||||
|
get-next-patch-version:
|
||||||
|
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
|
||||||
|
|
||||||
|
# Compute next relay patch version (e.g., 0.3.0 → 0.3.1)
|
||||||
|
get-next-relay-patch-version:
|
||||||
|
@python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
|
||||||
|
|
||||||
|
# Update version in desktop package manifests and regenerate lockfiles
|
||||||
|
bump-desktop-version version:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# desktop/package.json
|
||||||
|
cd desktop && npm pkg set "version={{ version }}" && cd ..
|
||||||
|
# desktop/src-tauri/tauri.conf.json
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const p = 'desktop/src-tauri/tauri.conf.json';
|
||||||
|
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||||
|
c.version = '{{ version }}';
|
||||||
|
fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
|
||||||
|
"
|
||||||
|
# JSON.stringify expands arrays/objects in a way biome rejects; reformat to match.
|
||||||
|
(cd desktop && pnpm exec biome format --write src-tauri/tauri.conf.json)
|
||||||
|
# desktop/src-tauri/Cargo.toml — only first version line (under [package])
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const p = 'desktop/src-tauri/Cargo.toml';
|
||||||
|
let t = fs.readFileSync(p, 'utf8');
|
||||||
|
t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"');
|
||||||
|
fs.writeFileSync(p, t);
|
||||||
|
"
|
||||||
|
# Regenerate lockfiles
|
||||||
|
pnpm install --lockfile-only
|
||||||
|
cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml
|
||||||
|
echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles"
|
||||||
|
|
||||||
|
# Bump the relay crate version and regenerate the lockfile
|
||||||
|
bump-relay-version version:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# buzz-relay carries its own `version =` (not version.workspace), so the
|
||||||
|
# replace targets the package version line only.
|
||||||
|
perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml
|
||||||
|
cargo update -p buzz-relay
|
||||||
|
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
|
||||||
|
|
||||||
|
# Open or update the desktop release PR from an immutable origin/main snapshot
|
||||||
|
release-desktop *ARGS:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ARG="{{ ARGS }}"
|
||||||
|
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
|
||||||
|
VERSION=$(just get-next-patch-version)
|
||||||
|
else
|
||||||
|
VERSION="$ARG"
|
||||||
|
fi
|
||||||
|
scripts/prepare-desktop-release.sh "$VERSION"
|
||||||
|
|
||||||
|
# Open or update the relay release PR (ghcr.io/block/buzz image)
|
||||||
|
release-relay *ARGS:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ARG="{{ ARGS }}"
|
||||||
|
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
|
||||||
|
VERSION=$(just get-next-relay-patch-version)
|
||||||
|
else
|
||||||
|
VERSION="$ARG"
|
||||||
|
fi
|
||||||
|
just _release-pr relay "$VERSION"
|
||||||
|
|
||||||
|
# Shared release-PR engine for desktop and relay. Mobile publishes immutable
|
||||||
|
# candidate tags directly from remote main instead of using metadata-only PRs.
|
||||||
|
_release-pr lane version:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="{{ version }}"
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Lane-specific identifiers. The bump command runs after the branch switch.
|
||||||
|
case "{{ lane }}" in
|
||||||
|
desktop)
|
||||||
|
BRANCH_PREFIX="version-bump"
|
||||||
|
TAG_FETCH='v*'
|
||||||
|
TAG_MATCH='v[0-9]*'
|
||||||
|
TAG_EXCLUDE='*-*'
|
||||||
|
TAG_PREFIX="v"
|
||||||
|
CHANGELOG="CHANGELOG.md"
|
||||||
|
ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md)
|
||||||
|
LOG_PATHS=(desktop/ crates/buzz-core/ crates/buzz-persona/ crates/buzz-sdk/ crates/buzz-agent/)
|
||||||
|
ARTIFACT="Buzz Desktop" ;;
|
||||||
|
relay)
|
||||||
|
BRANCH_PREFIX="relay-release"
|
||||||
|
TAG_FETCH='relay-v*'
|
||||||
|
TAG_MATCH='relay-v[0-9]*'
|
||||||
|
TAG_EXCLUDE='relay-v*-*'
|
||||||
|
TAG_PREFIX="relay-v"
|
||||||
|
CHANGELOG="crates/buzz-relay/CHANGELOG.md"
|
||||||
|
ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
|
||||||
|
LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
|
||||||
|
ARTIFACT="Buzz Relay" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: unknown release lane '{{ lane }}'"
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "Preparing ${ARTIFACT} release v${VERSION}..."
|
||||||
|
# Must run on main with a clean, up-to-date tree.
|
||||||
|
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
|
||||||
|
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||||
|
echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags
|
||||||
|
# Release tags are remote-owned state; sync only this lane's tags so stale
|
||||||
|
# local tags from older histories do not make release preflight fail.
|
||||||
|
git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}"
|
||||||
|
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
||||||
|
echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "Error: working tree is dirty. Commit or stash changes first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Switch to the release branch (create, or reset to main if it exists).
|
||||||
|
BRANCH="${BRANCH_PREFIX}/${VERSION}"
|
||||||
|
if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||||
|
echo "Branch '$BRANCH' already exists — resetting to origin/main..."
|
||||||
|
git switch "$BRANCH"
|
||||||
|
git reset --hard origin/main
|
||||||
|
elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
|
||||||
|
echo "Branch '$BRANCH' exists on remote — checking out and resetting to origin/main..."
|
||||||
|
git switch -c "$BRANCH" --track "origin/$BRANCH"
|
||||||
|
git reset --hard origin/main
|
||||||
|
else
|
||||||
|
git switch -c "$BRANCH"
|
||||||
|
fi
|
||||||
|
# Lane-specific bump (the one diverging step).
|
||||||
|
case "{{ lane }}" in
|
||||||
|
desktop) just bump-desktop-version "$VERSION" ;;
|
||||||
|
relay) just bump-relay-version "$VERSION" ;;
|
||||||
|
esac
|
||||||
|
# Generate the changelog from commits since this lane's last release tag.
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")
|
||||||
|
REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
format_log() {
|
||||||
|
local range="$1"
|
||||||
|
git log "$range" --format="%h %H %s" --no-merges -- "${LOG_PATHS[@]}" | while IFS=' ' read -r short full rest; do
|
||||||
|
local pr subject
|
||||||
|
pr=$(printf '%s' "$rest" | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' || true)
|
||||||
|
if [[ -n "$pr" ]]; then
|
||||||
|
subject=$(printf '%s' "$rest" | sed -E 's/ \(#[0-9]+\)$//')
|
||||||
|
printf -- '- %s ([#%s](https://github.com/%s/pull/%s)) ([`%s`](https://github.com/%s/commit/%s))\n' \
|
||||||
|
"$subject" "$pr" "$REPO" "$pr" "$short" "$REPO" "$full"
|
||||||
|
else
|
||||||
|
printf -- '- %s ([`%s`](https://github.com/%s/commit/%s))\n' \
|
||||||
|
"$rest" "$short" "$REPO" "$full"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
{
|
||||||
|
echo "# Changelog"
|
||||||
|
echo ""
|
||||||
|
echo "## ${TAG_PREFIX}${VERSION}"
|
||||||
|
echo ""
|
||||||
|
if [[ -n "$LAST_TAG" ]]; then
|
||||||
|
format_log "${LAST_TAG}..HEAD"
|
||||||
|
else
|
||||||
|
echo "- Initial release"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
if [[ -f "$CHANGELOG" ]]; then
|
||||||
|
tail -n +2 "$CHANGELOG"
|
||||||
|
fi
|
||||||
|
} > "$TMPFILE"
|
||||||
|
mkdir -p "$(dirname "$CHANGELOG")"
|
||||||
|
mv "$TMPFILE" "$CHANGELOG"
|
||||||
|
# Commit.
|
||||||
|
git add "${ADD_FILES[@]}"
|
||||||
|
RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}"
|
||||||
|
if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then
|
||||||
|
git commit --amend --no-edit
|
||||||
|
else
|
||||||
|
git commit -m "$RELEASE_MSG"
|
||||||
|
fi
|
||||||
|
# Push and open/update the PR.
|
||||||
|
git push --force-with-lease -u origin "$BRANCH"
|
||||||
|
PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n'
|
||||||
|
if [[ -n "$LAST_TAG" ]]; then
|
||||||
|
PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n'
|
||||||
|
CHANGELOG_BODY=$(format_log "${LAST_TAG}..HEAD~1")
|
||||||
|
MAX_LOG=62000
|
||||||
|
if (( ${#CHANGELOG_BODY} > MAX_LOG )); then
|
||||||
|
TRUNCATED=$(printf '%s' "$CHANGELOG_BODY" | awk -v max="$MAX_LOG" \
|
||||||
|
'BEGIN{n=0} {line_len=length($0)+1; if(n+line_len>max) exit; n+=line_len; print}')
|
||||||
|
SHOWN=$(printf '%s\n' "$TRUNCATED" | grep -c '^-' || true)
|
||||||
|
TOTAL=$(printf '%s\n' "$CHANGELOG_BODY" | grep -c '^-' || true)
|
||||||
|
SKIPPED=$(( TOTAL - SHOWN ))
|
||||||
|
CHANGELOG_BODY="${TRUNCATED}"$'\n'"_… and ${SKIPPED} more commits — [compare ${LAST_TAG}…${TAG_PREFIX}${VERSION}](https://github.com/${REPO}/compare/${LAST_TAG}...${TAG_PREFIX}${VERSION})_"
|
||||||
|
fi
|
||||||
|
PR_BODY+="${CHANGELOG_BODY}"$'\n\n'
|
||||||
|
else
|
||||||
|
PR_BODY+="Initial release."$'\n\n'
|
||||||
|
fi
|
||||||
|
PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically."
|
||||||
|
PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}"
|
||||||
|
EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true)
|
||||||
|
if [[ -n "$EXISTING_PR" ]]; then
|
||||||
|
gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY"
|
||||||
|
PR_URL="$EXISTING_PR"
|
||||||
|
echo ""
|
||||||
|
echo "Updated existing release PR: ${PR_URL}"
|
||||||
|
else
|
||||||
|
PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY")
|
||||||
|
echo ""
|
||||||
|
echo "Release PR opened: ${PR_URL}"
|
||||||
|
fi
|
||||||
|
echo "Merge it to trigger the release build."
|
||||||
|
|
||||||
|
# ─── Agent Harness ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run a goose agent connected to a Buzz relay (foreground)
|
||||||
|
goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
|
||||||
|
exec env "${env_args[@]}" ./target/release/buzz-acp
|
||||||
|
|
||||||
|
# Run a goose agent in the background (screen session named 'goose-agent-N')
|
||||||
|
goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$BUZZ_PRIVATE_KEY":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
source ./scripts/_goose-env.sh "{{relay}}" "{{key}}" "{{agents}}" "{{heartbeat}}" "{{prompt}}"
|
||||||
|
screen -dmS goose-agent-{{agents}} bash -c "$(printf '%q ' env "${env_args[@]}") ./target/release/buzz-acp"
|
||||||
|
echo "Agent running in screen session 'goose-agent-{{agents}}'. Attach with: screen -r goose-agent-{{agents}}"
|
||||||
|
|
||||||
|
# ─── Benchmarking ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run the Buzz orchestra benchmark — leaderboard-eligible by default (TB 2.1, k=5, Sonnet+Haiku). Stands up its own Docker stack; --gui opens a live spectator desktop app; other flags pass to benchmark.py (--dataset/--path, --include-task, --attempts, --manifest, --dry-run, ...)
|
||||||
|
benchmark *ARGS:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||||
|
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||||
|
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py {{ARGS}}
|
||||||
|
|
||||||
|
# Stop the benchmark Docker stack (state and channels are kept)
|
||||||
|
benchmark-down:
|
||||||
|
docker compose --project-name buzz-benchmark down
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Copyright 2026 Block, Inc.
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# Using Third-Party Nostr Clients with Buzz
|
||||||
|
|
||||||
|
Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed.
|
||||||
|
|
||||||
|
## Community scope
|
||||||
|
|
||||||
|
Buzz treats the relay URL/domain as authoritative for the community. Today's
|
||||||
|
single-relay deployment has exactly one community behind that URL, so existing
|
||||||
|
NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and
|
||||||
|
HTTP/media/git paths. In a multi-community deployment, each community is reached
|
||||||
|
by its own domain or subdomain; the backend resolves the community from the host
|
||||||
|
before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic.
|
||||||
|
|
||||||
|
The Nostr wire format does not grow a tenant tag. Client-supplied `#h` tags still
|
||||||
|
name channels/groups and are checked against the host-derived community. Events
|
||||||
|
without `#h` — profiles, gift-wrapped DMs, membership notifications, lists,
|
||||||
|
status, long-form notes, workflow/system events, and other "global" streams — are
|
||||||
|
global only inside the connected community. A pubkey can join multiple
|
||||||
|
communities and repost its profile in each one; DMs and profiles do not inherit
|
||||||
|
across community domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NIP-29 Direct
|
||||||
|
|
||||||
|
Connect any NIP-29 client straight to the relay.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup
|
||||||
|
export BUZZ_PUBKEY_ALLOWLIST=true
|
||||||
|
|
||||||
|
# 2. Start the relay (auto-starts Docker services and runs migrations)
|
||||||
|
just relay & # relay on :3000
|
||||||
|
|
||||||
|
# 3. Add a pubkey to the allowlist (if enabled)
|
||||||
|
# Insert directly — there is no CLI command for this yet.
|
||||||
|
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||||
|
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
|
||||||
|
|
||||||
|
# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Works
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|:------:|-------|
|
||||||
|
| **Group chat (kind:9)** | ✅ | Send/receive messages with `#h <channel-uuid>` tag |
|
||||||
|
| **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) |
|
||||||
|
| **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required |
|
||||||
|
| **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. |
|
||||||
|
| **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility` and `channel_type` |
|
||||||
|
| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. |
|
||||||
|
| **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. |
|
||||||
|
| **Edit group metadata (kind:9002)** | ✅ | `name`/`about` tags: owner/admin. `topic`/`purpose` tags: any member. |
|
||||||
|
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
|
||||||
|
| **Group deletion (kind:9008)** | ✅ | Owner only. |
|
||||||
|
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
|
||||||
|
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
|
||||||
|
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) |
|
||||||
|
| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members |
|
||||||
|
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
|
||||||
|
| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
|
||||||
|
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
|
||||||
|
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
|
||||||
|
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
|
||||||
|
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
|
||||||
|
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
|
||||||
|
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. |
|
||||||
|
| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. |
|
||||||
|
| **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. |
|
||||||
|
| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. |
|
||||||
|
| **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
|
||||||
|
| **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these |
|
||||||
|
|
||||||
|
### What Doesn't Work
|
||||||
|
|
||||||
|
| Feature | Status | Why |
|
||||||
|
|---------|:------:|-----|
|
||||||
|
| **Create invite (kind:9009)** | ⚠️ | Accepted and stored, but side-effect handler is deferred (no-op with warning log) |
|
||||||
|
| **Group roles (kind:39003)** | ❌ | Defined in kind registry but not emitted by the relay |
|
||||||
|
| **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. |
|
||||||
|
|
||||||
|
### Pubkey Allowlist
|
||||||
|
|
||||||
|
When `BUZZ_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
|
||||||
|
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
|
||||||
|
relay to specific external Nostr identities without granting full access.
|
||||||
|
|
||||||
|
- Users with valid **API tokens** bypass the allowlist.
|
||||||
|
- **Fail-closed:** if the DB lookup fails, the connection is denied.
|
||||||
|
- Default: `false` (all authenticated pubkeys accepted).
|
||||||
|
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
|
||||||
|
- Manage the allowlist via direct SQL (no CLI command yet):
|
||||||
|
```sql
|
||||||
|
INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'));
|
||||||
|
DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex');
|
||||||
|
SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group Discovery
|
||||||
|
|
||||||
|
The relay emits NIP-29 group state events when channels are created, updated, or membership changes.
|
||||||
|
All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention):
|
||||||
|
|
||||||
|
| Kind | Tags | Content |
|
||||||
|
|------|------|---------|
|
||||||
|
| **39000** | `d=<uuid>`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
|
||||||
|
| **39001** | `d=<uuid>`, `p` tags with role label (`owner`, `admin`) | Admin list |
|
||||||
|
| **39002** | `d=<uuid>`, `p` tags for all members | Member list |
|
||||||
|
|
||||||
|
Events are stored **channel-scoped** so access control applies — private channel member lists are
|
||||||
|
only visible to members. Discover groups via historical REQ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All groups you can see
|
||||||
|
nak req -k 39000 --auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Specific group's members (filter by d tag)
|
||||||
|
nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Channel-scoped storage means live global subscriptions (`{kinds:[39000]}`) won't
|
||||||
|
> receive these via fan-out. Clients discover groups via historical REQ queries. Live push for
|
||||||
|
> open-channel discovery is a future enhancement.
|
||||||
|
|
||||||
|
### Membership Notifications
|
||||||
|
|
||||||
|
The relay emits relay-signed notifications when members are added or removed:
|
||||||
|
|
||||||
|
| Kind | Meaning | Tags | Scope |
|
||||||
|
|------|---------|------|-------|
|
||||||
|
| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||||||
|
| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||||||
|
|
||||||
|
Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel
|
||||||
|
UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair
|
||||||
|
may sign these.
|
||||||
|
|
||||||
|
> **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must**
|
||||||
|
> include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects
|
||||||
|
> subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership
|
||||||
|
> changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Messages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Send a kind:9 message
|
||||||
|
nak event -k 9 -c "Hello from NIP-29!" --tag "h=<channel-uuid>" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Subscribe to channel messages
|
||||||
|
nak req -k 9 --tag "h=<channel-uuid>" --stream \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# React to a message (#h optional but recommended; channel derived from #e target)
|
||||||
|
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Subscribe to reactions to channel messages — include #h for live delivery (see note below)
|
||||||
|
nak req -k 7 --tag "h=<channel-uuid>" --stream \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Delete a message (#h optional; #e required; must be self-authored)
|
||||||
|
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Create a group
|
||||||
|
nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Search messages (NIP-50)
|
||||||
|
nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Reply to a message (NIP-10 threading)
|
||||||
|
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
|
||||||
|
--tag "e=<parent-event-id>;;reply" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
|
||||||
|
# Fetch gift-wrapped DMs (NIP-17)
|
||||||
|
nak req -k 1059 --tag "p=<your-hex-pubkey>" \
|
||||||
|
--auth --sec <privkey> ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is
|
||||||
|
> ignored for channel determination). Reactions to channel-scoped events are therefore
|
||||||
|
> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly
|
||||||
|
> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of
|
||||||
|
> those reactions — subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` instead.
|
||||||
|
> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit
|
||||||
|
> `h` tags are matched directly, and tagless reactions match via their stored channel.
|
||||||
|
|
||||||
|
### Tested Clients (Direct)
|
||||||
|
|
||||||
|
| Client | Platform | Evidence | Notes |
|
||||||
|
|--------|----------|:--------:|-------|
|
||||||
|
| **BuzzTestClient** | Rust (repo) | Automated E2E | Full NIP-29 flow: discovery (39000/39001/39002), kind:9 send/receive, reactions, deletions, h-tag enforcement |
|
||||||
|
| **E2E nostr interop** | Rust (repo) | Automated E2E | NIP-50 search (3 tests), NIP-10 threads (3 tests), NIP-17 gift wraps (3 tests), DM discovery (1 test) |
|
||||||
|
| **nak** | CLI | Manual (verified) | kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery |
|
||||||
|
|
||||||
|
**Not verified in-repo** (anecdotal / expected based on NIP-29 support):
|
||||||
|
- **Chachi** (Web/Mobile) — NDK-based; NIP-29 native
|
||||||
|
- **0xchat** (Mobile) — NIP-29 native
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay Membership (NIP-43)
|
||||||
|
|
||||||
|
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
|
||||||
|
`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner
|
||||||
|
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.
|
||||||
|
|
||||||
|
### CLI: Managing Members
|
||||||
|
|
||||||
|
Use `buzz-admin` — the operator CLI shipped in the relay image — to manage relay membership.
|
||||||
|
In a Docker Compose deployment, use `run.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a member (accepts bech32 npub or 64-char hex; default role: member)
|
||||||
|
./run.sh add-member npub1abc...
|
||||||
|
./run.sh add-member <64-char-hex-pubkey>
|
||||||
|
./run.sh add-member npub1abc... --role admin
|
||||||
|
|
||||||
|
# Remove a member
|
||||||
|
./run.sh remove-member npub1abc...
|
||||||
|
./run.sh remove-member npub1abc... --role member # only removes if role matches
|
||||||
|
|
||||||
|
# List all members
|
||||||
|
./run.sh list-members
|
||||||
|
```
|
||||||
|
|
||||||
|
Or invoke `buzz-admin` directly inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec relay buzz-admin add-member --pubkey npub1abc...
|
||||||
|
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role admin
|
||||||
|
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
|
||||||
|
docker compose exec relay buzz-admin list-members
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | Success |
|
||||||
|
| 1 | Validation error (bad pubkey, bad role, usage error) |
|
||||||
|
| 2 | Not found (remove: member does not exist) |
|
||||||
|
| 3 | Cannot remove relay owner (use `RELAY_OWNER_PUBKEY` to change owner) |
|
||||||
|
| 4 | Role mismatch (`--role` check failed) |
|
||||||
|
| 5 | DB/Redis/internal error |
|
||||||
|
|
||||||
|
**Required environment variables for member management:**
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|----------|-------|
|
||||||
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
|
| `REDIS_URL` | Redis connection string |
|
||||||
|
| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events |
|
||||||
|
|
||||||
|
### NIP-43 Admin Events (WebSocket)
|
||||||
|
|
||||||
|
Relay membership can also be managed over WebSocket using NIP-43 admin events. These require
|
||||||
|
the sender to be authenticated (NIP-42) as the relay owner or an admin.
|
||||||
|
|
||||||
|
| Kind | Action | Required tags |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||||||
|
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||||||
|
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
|
||||||
|
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
|
||||||
|
|
||||||
|
Example using `nak`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a member (owner or admin must sign)
|
||||||
|
nak event -k 9030 \
|
||||||
|
--tag "p=<target-hex-pubkey>" \
|
||||||
|
--tag "role=member" \
|
||||||
|
--auth --sec <owner-or-admin-privkey> \
|
||||||
|
ws://localhost:3000
|
||||||
|
|
||||||
|
# Remove a member
|
||||||
|
nak event -k 9031 \
|
||||||
|
--tag "p=<target-hex-pubkey>" \
|
||||||
|
--auth --sec <owner-or-admin-privkey> \
|
||||||
|
ws://localhost:3000
|
||||||
|
|
||||||
|
# Change a member's role to admin
|
||||||
|
nak event -k 9032 \
|
||||||
|
--tag "p=<target-hex-pubkey>" \
|
||||||
|
--tag "role=admin" \
|
||||||
|
--auth --sec <owner-or-admin-privkey> \
|
||||||
|
ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
After each add/remove/role-change, the relay publishes a kind:13534 membership list event
|
||||||
|
(relay-signed, NIP-70 protected) that clients can subscribe to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Subscribe to the live membership roster
|
||||||
|
nak req -k 13534 --auth --sec <privkey> ws://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
A kind:9033 command similarly makes the relay store the workspace icon (per
|
||||||
|
community) and serve it in the standard NIP-11 `icon` field of its relay
|
||||||
|
information document. Clients render it in the workspace rail/switcher; anyone
|
||||||
|
can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`),
|
||||||
|
but only admins/owners can set it. Full spec:
|
||||||
|
[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md).
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is
|
||||||
|
in-process-only (no Redis hop), so a sidecar call stores but never pushes. The 13534 list
|
||||||
|
snapshot is the authoritative roster and rides Redis to live clients. Do not wire a delta call
|
||||||
|
that passes in-process tests and silently no-ops in the deployed `compose exec` path.
|
||||||
|
|
||||||
|
2. **The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats same-second
|
||||||
|
domination for serial invocations; it does NOT serialize concurrent CLI processes** — two
|
||||||
|
near-simultaneous adds can read the same newest timestamp and collide on the bumped second.
|
||||||
|
`run.sh` serialization is the guard against parallel adds (e.g. `xargs -P`). When adding
|
||||||
|
multiple members in a loop, add `sleep 1` between invocations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay Environment Variables (NIP-29 relevant)
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
|
||||||
|
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
|
||||||
|
| `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### Direct Path
|
||||||
|
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
|
||||||
|
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
|
||||||
|
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
|
||||||
|
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
|
||||||
|
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).
|
||||||
|
- **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Direct Path
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response |
|
||||||
|
| `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h=<channel-uuid>"` |
|
||||||
|
| `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay |
|
||||||
|
| No discovery events | Channel is private + you're not a member | Join the channel first |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide).
|
||||||
|
- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents.
|
||||||
|
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals.
|
||||||
@@ -1,3 +1,288 @@
|
|||||||
# buzz-web-demo
|
<h1 align="center">Buzz 🐝</h1>
|
||||||
|
|
||||||
用于验证 Buzz Web 仓库入口和浏览功能
|
<p align="center">
|
||||||
|
<strong>A workspace where humans and agents build together, on a relay you own.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="VISION.md">Vision</a> ·
|
||||||
|
<a href="VISION_SOVEREIGN.md">Sovereign</a> ·
|
||||||
|
<a href="VISION_PROJECTS.md">Forge</a> ·
|
||||||
|
<a href="VISION_AGENT.md">Agents</a> ·
|
||||||
|
<a href="ARCHITECTURE.md">Architecture</a> ·
|
||||||
|
<a href="RELEASING.md">Releasing</a> ·
|
||||||
|
<a href="LICENSE">Apache 2.0</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/screenshots/channel-thread.png" alt="A Buzz project channel where people and an agent coordinate on a release plan" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub><em>People and agents building together in the same room.</em></sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is this, really?
|
||||||
|
|
||||||
|
Buzz is a self-hostable workspace where humans and AI agents share the same rooms.
|
||||||
|
|
||||||
|
A Buzz **community** is the workspace a user reaches by URL. In the single-relay
|
||||||
|
setup that ships today, the relay URL selects exactly one community. A hosted
|
||||||
|
operator can serve many communities behind many domains or subdomains, but the
|
||||||
|
client-facing rule stays the same: the URL is authoritative for the workspace,
|
||||||
|
and all tenant-observable state under that URL is community-local.
|
||||||
|
|
||||||
|
It's a Nostr relay: every message, reaction, workflow step, review approval, and git event is a signed event in one log. Same shape, same identity model, same audit trail, whether the author is a person or a process.
|
||||||
|
|
||||||
|
In practice it feels like a team workspace. Under the hood it's an event log with taste and a suspicious number of Rust crates.
|
||||||
|
|
||||||
|
Yes, it's another AI-adjacent developer tool. We're sorry. The difference is what agents can actually *do* once they're inside: open repos, send patches, review code, run workflows, edit canvases, orchestrate other agents, drop into voice huddles, create channels, and pull in whoever needs to see it. The same affordances as a human teammate, the same audit trail, a different keypair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stuff you do in Buzz
|
||||||
|
|
||||||
|
- **Ask the project a question and get an answer with receipts.** Agents search six months of history and post the threads, not vibes.
|
||||||
|
- **Let an agent triage a bug without giving it the keys to the kingdom.** Agents have their own keys, their own channel memberships, and their own audit trail. Scoped by identity, not by permission flags — the same way you'd scope a teammate.
|
||||||
|
- **Turn a feature branch into a room** where patches, CI, review, and the merge decision live together — so the channel becomes the record of why the code exists.
|
||||||
|
- **Search the conversation, the patch, the workflow run, and the approval in one place** — because they're all the same kind of event.
|
||||||
|
- **Let an agent run the workspace, not just talk in it.** Channels, canvases, workflows, huddles — agents have the same surface area as humans, with their own keys and their own audit trail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A look inside
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
<img src="docs/assets/screenshots/channel-agents.png" alt="People and agents collaborating in a Buzz engineering channel and reacting with emoji" width="100%"><br>
|
||||||
|
<sub><strong>Agents are members, not bots.</strong> Add an agent to a channel the same way you add a person.</sub>
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
<img src="docs/assets/screenshots/create-channel.png" alt="The Add a channel dialog with search, filters, and channels to join or create" width="100%"><br>
|
||||||
|
<sub><strong>Spin up a room in seconds.</strong> Name it, describe it, make it private.</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" valign="top">
|
||||||
|
<img src="docs/assets/screenshots/media-comments.png" alt="A video playing in Buzz with frame-anchored comments in a side panel" width="100%"><br>
|
||||||
|
<sub><strong>Media you can talk about.</strong> Leave comments pinned to specific frames.</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Buzz is better
|
||||||
|
|
||||||
|
One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, and object storage.
|
||||||
|
|
||||||
|
The bet is that one community can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other.
|
||||||
|
|
||||||
|
Agents are part of the room, not haunted cron jobs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three little stories
|
||||||
|
|
||||||
|
**Incident memory.** It's 2am. You type *"have we seen this error before?"* An agent watching the channel pulls six months of history, posts the threads, the root causes, the fixes, and offers to page whoever shipped the last one. The whole exchange — question, answer, evidence — stays in the channel.
|
||||||
|
|
||||||
|
**Branch as room.** You open a feature branch. A channel appears. Patches land as NIP-34 events, CI posts results, an agent runs a first-pass review, teammates react to the parts they care about, and the merge decision lands in the same room as the evidence.
|
||||||
|
|
||||||
|
**A release that writes itself.** A workflow fires on a tag. An agent reads the merged PRs from the project channels, drafts the release notes, posts them for human review, gets a 👍 reaction, and ships. Every step signed. Every step searchable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Works today · Being wired up · Strong opinions, pending code
|
||||||
|
|
||||||
|
| ✅ Works today | 🚧 Being wired up | 💭 Strong opinions, pending code |
|
||||||
|
|---|---|---|
|
||||||
|
| Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays |
|
||||||
|
| Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications |
|
||||||
|
| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features |
|
||||||
|
| YAML workflows: message / reaction / schedule / webhook triggers | | |
|
||||||
|
| Git events (NIP-34: patches, repo announcements, status) | | |
|
||||||
|
| Git hosting backend | | |
|
||||||
|
|
||||||
|
<sub>Please do not plan your compliance program around the 💭 column yet. The <a href="VISION.md">VISION docs</a> are the long version of what we think this becomes.</sub>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
New to Buzz? Pick the path that matches you.
|
||||||
|
|
||||||
|
### I just want to try the app
|
||||||
|
|
||||||
|
Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest):
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|---|---|
|
||||||
|
| macOS (Apple Silicon) | `Buzz_<version>_aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `Buzz_<version>_x64.dmg` |
|
||||||
|
| Linux (x86_64) | `Buzz_<version>_amd64.AppImage` or `Buzz_<version>_amd64.deb` |
|
||||||
|
| Windows (x64) | `Buzz_<version>_x64-setup_alpha-unsigned.exe` |
|
||||||
|
|
||||||
|
On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel.
|
||||||
|
|
||||||
|
The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**.
|
||||||
|
|
||||||
|
|
||||||
|
By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally.
|
||||||
|
|
||||||
|
### I want my own hosted relay
|
||||||
|
|
||||||
|
To run a relay for your team without managing servers, you can deploy one to Railway in a click:
|
||||||
|
|
||||||
|
[](https://railway.com/deploy/buzz-relay-block)
|
||||||
|
|
||||||
|
See [here](https://engineering.block.xyz/blog/run-your-own-buzz-relay) for details.
|
||||||
|
|
||||||
|
### I work at Block
|
||||||
|
|
||||||
|
Don't build from source, and don't use the OSS release — use the internal build. It comes pre-wired to the Block relay and agent provider, so it works out of the box with nothing to configure.
|
||||||
|
|
||||||
|
Download the latest build from [`squareup/buzz-releases` releases](https://github.com/squareup/buzz-releases/releases/latest) and install it.
|
||||||
|
|
||||||
|
### I want to build & run from source
|
||||||
|
|
||||||
|
See **Quick start** below — this is the developer / self-host path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`).
|
||||||
|
|
||||||
|
**Once:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/block/buzz.git && cd buzz
|
||||||
|
. ./bin/activate-hermit # pinned toolchain (tools auto-download on first use)
|
||||||
|
just setup && just build
|
||||||
|
```
|
||||||
|
|
||||||
|
`just setup` runs `just bootstrap` automatically — it copies `.env.example` to `.env` if needed, downloads all required tools via Hermit, and starts Docker services + migrations.
|
||||||
|
|
||||||
|
**Every day:**
|
||||||
|
```bash
|
||||||
|
. ./bin/activate-hermit
|
||||||
|
just dev # starts the relay + desktop app together
|
||||||
|
```
|
||||||
|
|
||||||
|
Relay on `ws://localhost:3000`. Desktop app pops up. You're in.
|
||||||
|
|
||||||
|
For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another.
|
||||||
|
|
||||||
|
Want a single-node / VPS relay instead of the local-dev stack? Use the production Compose bundle in [`deploy/compose/`](deploy/compose/README.md) (`docker compose` + Postgres, Redis, MinIO, optional Caddy/TLS). The root [`docker-compose.yml`](docker-compose.yml) is for day-to-day development only.
|
||||||
|
|
||||||
|
For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows prerequisites
|
||||||
|
|
||||||
|
The agent shell tool runs commands under bash. On macOS and Linux that's already there; on Windows you need to bring it.
|
||||||
|
|
||||||
|
Install [Git for Windows](https://git-scm.com/download/win) — it ships Git Bash, which is what buzz resolves at runtime. Once it's installed, everything works the same as on other platforms.
|
||||||
|
|
||||||
|
If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL` to its path (e.g. `BUZZ_SHELL=C:\path\to\bash.exe`). The agent's tool description updates automatically to reflect whichever shell is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Clients │
|
||||||
|
│ Human client AI agent CLI / scripts │
|
||||||
|
│ (Buzz desktop) (Goose, Codex, ...) (buzz-cli, agents) │
|
||||||
|
│ │ ┌──────────────┐ │ │
|
||||||
|
│ │ │ buzz-acp │ │ │
|
||||||
|
│ │ │ (ACP ↔ MCP) │ │ │
|
||||||
|
│ │ └──────┬───────┘ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
└───────┼──────────────────────┼───────────────────────┼──────────────────┘
|
||||||
|
│ WebSocket │ WS + REST │ WS + REST
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ buzz-relay │
|
||||||
|
│ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │
|
||||||
|
└───┬──────────────────────────┬──────────────────────────┬───────────────┘
|
||||||
|
│ │ │
|
||||||
|
┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐
|
||||||
|
│ Postgres │ │ Redis │ │ S3/MinIO │
|
||||||
|
│ (events + │ │ (pub/sub) │ │ (Blossom) │
|
||||||
|
│ FTS search) │ └─────────────┘ └─────────────┘
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
A Rust workspace of focused crates. Single source of truth: the relay. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Crate map</strong></summary>
|
||||||
|
|
||||||
|
**Core protocol** — `buzz-core` (zero-I/O types, NIP-01 filters, Schnorr verify) · `buzz-relay` (Axum WS + REST)
|
||||||
|
|
||||||
|
**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace.
|
||||||
|
|
||||||
|
**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs)
|
||||||
|
|
||||||
|
**Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing)
|
||||||
|
|
||||||
|
**Shared** — `buzz-sdk` (typed event builders) · `buzz-media` (Blossom/S3)
|
||||||
|
|
||||||
|
**Tooling** — `buzz-admin` (admin CLI) · `buzz-test-client` (E2E)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Going further
|
||||||
|
|
||||||
|
- **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs
|
||||||
|
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries
|
||||||
|
- **[TESTING.md](TESTING.md)** — multi-agent E2E test suite
|
||||||
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Configuration</strong> (env vars, defaults work for local dev)</summary>
|
||||||
|
|
||||||
|
All defaults work out of the box. Override via `.env`. Full reference in [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Common dev commands</strong></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just setup # Docker, migrations, desktop deps
|
||||||
|
just relay # Run the relay
|
||||||
|
just dev # Run the desktop app
|
||||||
|
just build # Build the Rust workspace
|
||||||
|
just check # fmt + clippy + desktop check
|
||||||
|
just test-unit # Unit tests (no infra required)
|
||||||
|
just test # Full suite (starts services if needed)
|
||||||
|
just ci # Everything CI runs
|
||||||
|
just reset # ⚠️ Wipe data + recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
- Not blockchain. Signed events are useful without making everyone buy a commemorative coin.
|
||||||
|
- Not an AI replacement plan. Buzz works best when humans stay in the loop and agents stay in the room.
|
||||||
|
- Not finished. We will tell you what works and what doesn't.
|
||||||
|
|
||||||
|
**What it is:** one relay where humans, agents, workflows, git events, and project memory cooperate — the beginning of a workspace that can grow past the tabs it replaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Buzz 🐝</sub><br>
|
||||||
|
<sub>Apache 2.0 · Built by <a href="https://block.xyz">Block, Inc.</a></sub>
|
||||||
|
</p>
|
||||||
|
|||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
# Releasing Buzz
|
||||||
|
|
||||||
|
Buzz has three independent release lanes. Desktop and relay use release PRs.
|
||||||
|
Mobile uses immutable release-candidate tags cut directly from remote `main`:
|
||||||
|
|
||||||
|
| Lane | Entry point | Artifact |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Desktop | `just release-desktop <version>` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
|
||||||
|
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
|
||||||
|
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
|
||||||
|
|
||||||
|
The lanes version independently. Desktop reads its manifests, relay reads its
|
||||||
|
crate manifest, and mobile derives both source and marketing version from the
|
||||||
|
exact candidate tag. The mobile handoff to the private `buzz-releases` pipeline
|
||||||
|
remains manual because OSS CI cannot trigger private CI.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Prepare desktop releases locally from an up-to-date, clean `main` checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just release-desktop 0.5.3
|
||||||
|
```
|
||||||
|
|
||||||
|
The recipe generates the immutable candidate and opens or updates its pull
|
||||||
|
request. Candidate branch creation uses the operator's GitHub permissions; the
|
||||||
|
release App is intentionally limited to creating protected release tags.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Relay release
|
||||||
|
just release-relay
|
||||||
|
just release-relay 0.4.0
|
||||||
|
|
||||||
|
# Publish the next mobile candidate from the exact current remote main commit
|
||||||
|
scripts/mobile-release.sh candidate 0.5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop uses an immutable generated candidate PR; relay continues using its
|
||||||
|
metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable
|
||||||
|
candidate and the artifact of record.
|
||||||
|
There is no mobile release branch, stable mobile tag alias, finalization step,
|
||||||
|
or mobile GitHub Release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
|
||||||
|
The script creates one deterministic candidate commit and records both its
|
||||||
|
frozen base and the verified prior release ledger in candidate metadata.
|
||||||
|
2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or
|
||||||
|
pushing the branch creates a new candidate and requires checks to run again.
|
||||||
|
3. **Squash merge** the PR after all protected-branch checks pass. The merge is
|
||||||
|
the human authorization event; an authorized owner/admin bypass is treated
|
||||||
|
the same way. Unrelated changes reaching `main` do not invalidate the
|
||||||
|
reviewed candidate.
|
||||||
|
4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR
|
||||||
|
identity, validates candidate content, and proves every required check came
|
||||||
|
from its trusted producer and was successful when the PR merged. It creates
|
||||||
|
`desktop-v<version>` at the exact reviewed PR head—not the squash commit.
|
||||||
|
Retries accept that tag only at the same SHA and never move it. GitHub does
|
||||||
|
not expose when an individual check rerun was created, so an ordinary rerun
|
||||||
|
after merge deliberately makes tag verification fail closed; inspect that
|
||||||
|
run and create a new candidate version rather than retrying the blocked tag.
|
||||||
|
5. The tag triggers `release.yml`. It builds and stages all platform artifacts,
|
||||||
|
publishes the versioned release only after the complete set succeeds, then
|
||||||
|
updates the rolling updater manifest last for stable versions.
|
||||||
|
|
||||||
|
Because squash merging leaves immutable candidate tags on side history, the next
|
||||||
|
release uses validated prior candidate metadata as its ledger boundary. It
|
||||||
|
includes unrelated commits after the prior frozen base and excludes exactly the
|
||||||
|
prior release's recorded squash commit; tag ancestry is deliberately irrelevant.
|
||||||
|
|
||||||
|
### Relay
|
||||||
|
|
||||||
|
1. **`just release-relay`** runs locally on `main`, creates or updates a
|
||||||
|
`relay-release/<version>` PR, bumps `crates/buzz-relay/Cargo.toml`,
|
||||||
|
regenerates `Cargo.lock`, and updates the relay changelog.
|
||||||
|
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes
|
||||||
|
`relay-v<version>`.
|
||||||
|
3. **The tag triggers `docker.yml`.** Stable releases update the version
|
||||||
|
aliases and `latest`; prereleases do not. Each release also publishes an
|
||||||
|
optimized, symbol-bearing image under matching `debug-` tags (for example,
|
||||||
|
`debug-0.3.0` and `debug-latest`) for native profiling. The ordinary tags
|
||||||
|
remain stripped and are the default for deployments that do not need it.
|
||||||
|
|
||||||
|
Every push to `main` continues to publish the rolling relay `:main` and
|
||||||
|
`:sha-<7>` tags, plus matching `:debug-main` and `:debug-sha-<7>` variants.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
1. **Publish a candidate.** From a clean checkout whose `origin` is the
|
||||||
|
canonical `block/buzz` repository, run
|
||||||
|
`scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches
|
||||||
|
the exact current `origin/main` commit, derives the next number from exact
|
||||||
|
remote tags for that marketing version, and publishes an annotated
|
||||||
|
`mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot`
|
||||||
|
GitHub App. It never uses the operator's checked-out commit and never moves
|
||||||
|
an existing candidate.
|
||||||
|
2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the
|
||||||
|
private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger
|
||||||
|
that private pipeline. The tag supplies both source commit and release
|
||||||
|
version. Flutter receives clean marketing version `X.Y.Z`; Buildkite's
|
||||||
|
monotonically increasing build number supplies the platform build number.
|
||||||
|
3. **Promote tested artifacts.** Promote the already-built signed artifact for
|
||||||
|
each platform through its store workflow. Record the exact tag with the
|
||||||
|
build or rollout record. No source ref is changed and no final build is cut.
|
||||||
|
|
||||||
|
The iOS and Android artifacts for one marketing version may come from different
|
||||||
|
RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships
|
||||||
|
`mobile-v0.5.0-rc.3`. Each platform's exact candidate tag is its source record.
|
||||||
|
There is intentionally no single selected or final candidate for the marketing
|
||||||
|
version.
|
||||||
|
|
||||||
|
The simplification trades away a separate stabilization line. Unrelated commits
|
||||||
|
that reach `main` become part of every later candidate, and there is no retained
|
||||||
|
hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a
|
||||||
|
release actually needs isolation from `main`.
|
||||||
|
|
||||||
|
`mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release
|
||||||
|
fallback for local development and validation builds. Release jobs always
|
||||||
|
inject both version fields. `mobile/CHANGELOG.md` is retained as historical
|
||||||
|
release data. It is not a release ledger for this flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Sources
|
||||||
|
|
||||||
|
| Lane | Release version authority |
|
||||||
|
|------|---------------------------|
|
||||||
|
| Desktop | `desktop/package.json` and synchronized desktop manifests |
|
||||||
|
| Relay | `crates/buzz-relay/Cargo.toml` |
|
||||||
|
| Mobile | Exact `mobile-vX.Y.Z-rc.N` remote tag |
|
||||||
|
|
||||||
|
`just bump-desktop-version <version>` updates the desktop manifests and
|
||||||
|
regenerates their lockfiles. `just bump-relay-version <version>` updates the
|
||||||
|
relay crate and regenerates `Cargo.lock`. Mobile has no bump recipe or
|
||||||
|
release-metadata PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signed macOS Canary
|
||||||
|
|
||||||
|
Use the manual **Signed macOS Canary** workflow when you need an Apple Silicon
|
||||||
|
build of current `main` for explicit testing without publishing a release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh workflow run signed-macos-canary.yml --repo block/buzz --ref main
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow derives a `-test.<run-number>` version, signs and notarizes the
|
||||||
|
DMG, verifies it with Gatekeeper, and uploads it as a short-lived Actions
|
||||||
|
artifact with seven-day retention. Because this is a public repository, any
|
||||||
|
signed-in GitHub user can download that artifact while it exists; it is
|
||||||
|
unpublished, not private. The workflow has no release permissions, does not
|
||||||
|
create or move tags, and cannot update `buzz-desktop-latest` or `latest.json`.
|
||||||
|
|
||||||
|
Download the artifact from the completed run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh run download <run-id> --repo block/buzz --name <artifact-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow intentionally accepts only `main`. Use the normal release process
|
||||||
|
for distributable builds or builds from an immutable release tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Retry
|
||||||
|
|
||||||
|
`release.yml` has no manual dispatch and cannot build from `main` or another
|
||||||
|
caller-selected ref. If a run for an existing immutable
|
||||||
|
`desktop-v<version>` tag fails, rerun that failed workflow from GitHub Actions
|
||||||
|
(or use `gh run rerun <run-id> --failed --repo block/buzz`). A stable rerun also
|
||||||
|
repairs `buzz-desktop-latest/latest.json` if the original run published the
|
||||||
|
versioned release but failed during that final rolling-manifest upload. Do not
|
||||||
|
recreate, move, or push the immutable tag again.
|
||||||
|
|
||||||
|
Mobile intentionally has no branch or arbitrary-ref fallback. The private
|
||||||
|
Buildkite pipeline accepts only an exact candidate tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal Releases
|
||||||
|
|
||||||
|
For mobile, trigger the private
|
||||||
|
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
|
||||||
|
an exact RC tag for the platform build being cut. For desktop, start
|
||||||
|
[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the
|
||||||
|
exact public source tag as `desktop_ref=desktop-v<version>`; a generic
|
||||||
|
`v<version>` tag is intentionally rejected. See the
|
||||||
|
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
|
||||||
|
for the rest of the private pipeline contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Published
|
||||||
|
|
||||||
|
Desktop publishes two GitHub releases:
|
||||||
|
|
||||||
|
1. **`desktop-v<version>`**: the user-facing release with installers.
|
||||||
|
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
|
||||||
|
|
||||||
|
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
|
||||||
|
and rollout records retain the exact tag they used. Mobile does not publish a
|
||||||
|
GitHub Release or a stable `mobile-vX.Y.Z` alias.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
The release workflow builds **two separate macOS DMGs**: Apple
|
||||||
|
Silicon (`darwin-aarch64`, the `release` job) and Intel
|
||||||
|
(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64
|
||||||
|
NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and
|
||||||
|
`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached
|
||||||
|
to the same `desktop-v<version>` release. Intel users
|
||||||
|
download the `_x64.dmg`.
|
||||||
|
|
||||||
|
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
|
||||||
|
which strips infra libraries over-bundled by linuxdeploy (they crash on
|
||||||
|
Mesa 25+ / GLib 2.88 distros; see
|
||||||
|
[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665))
|
||||||
|
and re-signs the artifact. As a result the AppImage relies on the
|
||||||
|
host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
|
||||||
|
(Ubuntu 22.04 or newer). The `release-linux` job builds inside a
|
||||||
|
`ubuntu:22.04` container for broad GLIBC compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Write access** to the `block/buzz` GitHub repository
|
||||||
|
- An `origin` remote whose configured URL is the canonical `block/buzz`
|
||||||
|
repository
|
||||||
|
- `gh` CLI authenticated with permission to push the candidate branch and open
|
||||||
|
its pull request
|
||||||
|
- The Default `main` ruleset configured for squash-only merging, strict required
|
||||||
|
checks, stale-review dismissal, and the **Desktop Release Candidate** check
|
||||||
|
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
|
||||||
|
active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
|
||||||
|
non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
|
||||||
|
actor
|
||||||
|
- The `buzz-release-bot` App credentials configured for GitHub Actions
|
||||||
|
- The following **GitHub Actions variables and secrets** configured for the
|
||||||
|
desktop release lane:
|
||||||
|
|
||||||
|
| Name | Kind | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags |
|
||||||
|
| `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
|
||||||
|
| `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
|
||||||
|
| `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
|
||||||
|
| `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key |
|
||||||
|
| `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
|
||||||
|
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
|
||||||
|
|
||||||
|
Mobile candidate publication requires workflow-dispatch access and the existing
|
||||||
|
release App because strict tag protection denies direct human creation. The App
|
||||||
|
must be installed on `block/buzz`, have Contents write and Metadata read, and
|
||||||
|
retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not
|
||||||
|
require GitHub Releases permissions, repository Administration permission, or a
|
||||||
|
mobile release-branch ruleset. The publisher validates the App token's effective
|
||||||
|
`current_user_can_bypass` value rather than reading the ruleset's hidden bypass
|
||||||
|
actor list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### The desktop candidate is stale or cannot be squash merged
|
||||||
|
|
||||||
|
Do not update the branch manually and do not weaken the ruleset. Run
|
||||||
|
`just release-desktop <version>` again from current `main`; this regenerates the
|
||||||
|
candidate, reruns CI, and requires a fresh trusted approval on the new exact
|
||||||
|
head. The post-merge verifier refuses to tag a squash whose parent differs from
|
||||||
|
the recorded candidate base or whose tree differs from the validated PR head.
|
||||||
|
|
||||||
|
### Local `just release-desktop` fails with "must be on main branch"
|
||||||
|
Switch to `main` and pull latest before running the release recipe.
|
||||||
|
|
||||||
|
### Local `just release-desktop` fails with "working tree is dirty"
|
||||||
|
Commit or stash your changes before running the release recipe.
|
||||||
|
|
||||||
|
### New commits land after publishing a mobile candidate
|
||||||
|
|
||||||
|
Run `scripts/mobile-release.sh candidate <version>` again after the intended
|
||||||
|
fix reaches remote `main`. It publishes a new immutable RC tag at the new exact
|
||||||
|
remote commit. Continue referring to each tested or shipped platform artifact by
|
||||||
|
its own exact tag.
|
||||||
|
|
||||||
|
### `scripts/mobile-release.sh candidate` fails because `main` moved during publication
|
||||||
|
|
||||||
|
The App-backed workflow may already have published the requested immutable RC
|
||||||
|
at the prior `main` tip before the operator command detects the race. Do not
|
||||||
|
move or delete that tag, and do not treat it as the candidate for current
|
||||||
|
`main`. Inspect the run URL from the command output, then rerun
|
||||||
|
`scripts/mobile-release.sh candidate <version>` to publish the next RC from the
|
||||||
|
new current `main` tip.
|
||||||
|
|
||||||
|
### A mobile candidate command selects the wrong RC number
|
||||||
|
|
||||||
|
Do not retry by moving or deleting a tag. Inspect the exact remote `mobile-v*`
|
||||||
|
tags and resolve the unexpected state. Candidate numbers are monotonically
|
||||||
|
increasing remote identities.
|
||||||
|
|
||||||
|
### A mobile candidate publication is rejected by repository rules
|
||||||
|
|
||||||
|
Confirm `buzz-release-bot` remains the sole always-bypass actor for the active
|
||||||
|
`mobile-v*` ruleset and that its Actions credentials are available. Do not grant
|
||||||
|
direct human creation or weaken update or deletion protection. Existing
|
||||||
|
candidate tags must remain immutable.
|
||||||
|
|
||||||
|
### Auto-updater reports "no update available"
|
||||||
|
Verify that the `buzz-desktop-latest` release exists and contains a
|
||||||
|
valid `latest.json`. The manifest covers all four platform keys
|
||||||
|
(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`,
|
||||||
|
`windows-x86_64`); a missing entry usually means that platform's
|
||||||
|
release job failed. Check the workflow run.
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Buzz, please report it by emailing
|
||||||
|
**buzz@block.xyz**. Include as much detail as possible:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its potential impact
|
||||||
|
- Steps to reproduce or a proof-of-concept (if available)
|
||||||
|
- The affected version(s) or commit range
|
||||||
|
- Any suggested mitigations you've identified
|
||||||
|
|
||||||
|
You will receive an acknowledgment within **48 hours**. We aim to provide a
|
||||||
|
full response — including a timeline for a fix — within **7 days** of initial
|
||||||
|
contact. We'll keep you informed as we work toward a resolution.
|
||||||
|
|
||||||
|
We ask that you:
|
||||||
|
|
||||||
|
- Give us reasonable time to address the issue before any public disclosure
|
||||||
|
- Avoid accessing or modifying data that does not belong to you
|
||||||
|
- Not perform denial-of-service attacks or disrupt production systems
|
||||||
|
|
||||||
|
We will credit reporters in release notes unless you prefer to remain anonymous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| `main` (latest) | ✅ Active |
|
||||||
|
| Previous releases | ⚠️ Best-effort; upgrade recommended |
|
||||||
|
|
||||||
|
Buzz is pre-1.0. We do not maintain long-term support branches at this stage.
|
||||||
|
All security fixes land on `main` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Design Principles
|
||||||
|
|
||||||
|
### Authentication — NIP-42
|
||||||
|
|
||||||
|
Every connection to the relay must authenticate via
|
||||||
|
[NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md)
|
||||||
|
challenge/response before writing events. The relay sends a random challenge;
|
||||||
|
the client signs a `kind:22242` event containing the challenge and the relay
|
||||||
|
URL, proving possession of the private key.
|
||||||
|
|
||||||
|
REST endpoints authenticate via
|
||||||
|
[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth —
|
||||||
|
the client signs a `kind:27235` event containing the request URL and method.
|
||||||
|
The relay verifies the Schnorr signature and extracts the pubkey.
|
||||||
|
|
||||||
|
### Authorization — Channel Membership as the Gate
|
||||||
|
|
||||||
|
Channel membership is the **only** access control mechanism. There are no
|
||||||
|
separate ACL lists or capability taxonomies. If a principal (human or agent)
|
||||||
|
is a member of a channel, they can read and write to it. If they are not a
|
||||||
|
member, the relay rejects their requests — even if they are authenticated.
|
||||||
|
|
||||||
|
Private channels are invisible to non-members: they do not appear in channel
|
||||||
|
listings, and subscription filters for private channel events return nothing
|
||||||
|
unless the subscriber is a member.
|
||||||
|
|
||||||
|
### Append-Only Audit Log
|
||||||
|
|
||||||
|
All events are written to a tamper-evident audit log (`buzz-audit`). Each
|
||||||
|
log entry is chained to the previous one via a SHA-256 hash chain. Because the
|
||||||
|
chain is keyless, it is tamper-evident but not tamper-resistant: it detects
|
||||||
|
accidental corruption or single-row edits, but an attacker with database write
|
||||||
|
access can recompute the entire chain after editing. The audit log is designed
|
||||||
|
for SOX-grade compliance and eDiscovery.
|
||||||
|
|
||||||
|
### Desktop Secret Storage — OS Keyring
|
||||||
|
|
||||||
|
The Buzz desktop app stores nsec private keys in the operating system keyring
|
||||||
|
rather than in plaintext files: macOS Keychain, Windows Credential Manager, or
|
||||||
|
the Linux Secret Service (`gnome-keyring` / `kwallet` via D-Bus). This covers
|
||||||
|
both the human identity key and every managed-agent key.
|
||||||
|
|
||||||
|
On first launch after upgrading, existing plaintext keys are migrated into the
|
||||||
|
keyring: the key is imported, read back to verify the round-trip, and only then
|
||||||
|
is the plaintext deleted. Migration runs only when the keyring is reachable —
|
||||||
|
if the backend is unavailable that session, the app keeps reading from the
|
||||||
|
plaintext file and does **not** migrate, so a transient outage cannot resurrect
|
||||||
|
a rotated key from a leftover file.
|
||||||
|
|
||||||
|
When no keyring backend is available (headless Linux with no Secret Service, for
|
||||||
|
example), keys fall back to a `0o600` owner-only file. The `BUZZ_PRIVATE_KEY`
|
||||||
|
environment variable, when set, always takes precedence over both stores — this
|
||||||
|
is how harnessed agents and CI receive their identity.
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- All UUIDs (channel IDs, workflow IDs) are validated at API boundaries before
|
||||||
|
use in database queries.
|
||||||
|
- Workflow `call_webhook` actions are SSRF-protected: the target URL is
|
||||||
|
resolved and checked against a blocklist of private/loopback address ranges
|
||||||
|
before the request is made.
|
||||||
|
- Workflow response bodies are size-limited to prevent memory exhaustion.
|
||||||
|
- `evalexpr` condition evaluation is sandboxed and timeout-bounded.
|
||||||
|
- Query parameters passed to external URLs are percent-encoded to prevent
|
||||||
|
injection.
|
||||||
|
|
||||||
|
### Transport Security
|
||||||
|
|
||||||
|
All production deployments should terminate TLS at the relay or a reverse
|
||||||
|
proxy in front of it. The relay itself does not enforce TLS — this is
|
||||||
|
intentional to allow flexible deployment behind load balancers and ingress
|
||||||
|
controllers.
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
We use `cargo audit` in CI to scan for known vulnerabilities in dependencies.
|
||||||
|
`#![deny(unsafe_code)]` is enforced across all crates — no unsafe Rust.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disclosure Policy
|
||||||
|
|
||||||
|
We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||||
|
Once a fix is ready and released, we will publish a security advisory on
|
||||||
|
GitHub describing the vulnerability, its impact, and the fix. Reporters will
|
||||||
|
be credited unless they request anonymity.
|
||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Automated Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test-unit # unit tests — no infrastructure needed
|
||||||
|
just test # unit + integration (starts Docker if needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
`just test` runs unit tests plus integration tests against Postgres and Redis
|
||||||
|
(started automatically if not already running). Neither task runs the E2E suites in
|
||||||
|
`buzz-test-client` — those are marked `#[ignore]` and require a running relay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a relay first (see below), then:
|
||||||
|
cargo test -p buzz-test-client -- --ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live Local Relay
|
||||||
|
|
||||||
|
The fastest way to exercise the relay end-to-end is to build the release
|
||||||
|
binaries once, run `buzz-relay`, and drive it with the `buzz` CLI. The
|
||||||
|
CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled
|
||||||
|
`curl`.
|
||||||
|
|
||||||
|
### 1. Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. ./bin/activate-hermit # activate pinned toolchain
|
||||||
|
cp .env.example .env # one-time
|
||||||
|
just setup # start Docker services, run migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Already running Buzz Desktop?** Desktop uses the same Docker container
|
||||||
|
> names (`buzz-postgres`, `buzz-redis`) and the same
|
||||||
|
> default ports (`:5432`, `:6379`). `just setup` will reuse those
|
||||||
|
> services, so **your test relay writes into Desktop's database**. That's
|
||||||
|
> fine for read/write smoke tests, but: `just reset` wipes Desktop's data
|
||||||
|
> along with yours. If you need isolation, stop Desktop first or run the
|
||||||
|
> dev stack on a different Compose project
|
||||||
|
> (`COMPOSE_PROJECT_NAME=buzz-dev docker compose …`).
|
||||||
|
|
||||||
|
`just reset` wipes all local data and starts over — **including Buzz
|
||||||
|
Desktop's data** if its services are sharing your dev stack (see callout
|
||||||
|
above).
|
||||||
|
|
||||||
|
> **Heads up — scrub stale env first.** If your shell inherits any of
|
||||||
|
> `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, or `BUZZ_PRIVATE_KEY` from a
|
||||||
|
> prior session (or a staging config), `unset` them before continuing.
|
||||||
|
> A stale `BUZZ_AUTH_TAG` fails the **local dev relay** with
|
||||||
|
> `auth_error: signature verification failed` on the first CLI write —
|
||||||
|
> it is *not* tolerated.
|
||||||
|
> ```bash
|
||||||
|
> unset BUZZ_AUTH_TAG BUZZ_RELAY_URL BUZZ_PRIVATE_KEY
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2. Build the binaries
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release -p buzz-relay -p buzz-cli -p buzz-admin
|
||||||
|
export PATH="$PWD/target/release:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild after any code change — the steps below use the release binaries.
|
||||||
|
|
||||||
|
### 3. Start the relay
|
||||||
|
|
||||||
|
In a separate terminal (it runs in the foreground):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
buzz-relay # release binary from step 2, serves ws://localhost:3000
|
||||||
|
# alternatives:
|
||||||
|
# cargo run --release -p buzz-relay # rebuild + run in release
|
||||||
|
# just relay # DEBUG build — fast to launch on a hot cache,
|
||||||
|
# # but mismatched if step 2 left you on release.
|
||||||
|
# # Use `just relay-release` if you want the recipe.
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify it's up (back in your working terminal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:3000/health # → ok
|
||||||
|
curl -s http://localhost:8080/_readiness # → {"status":"ready"}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Health/readiness/liveness live on a **separate port** (default `8080`,
|
||||||
|
> `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app
|
||||||
|
> port also exposes `/health` for convenience.
|
||||||
|
|
||||||
|
The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup
|
||||||
|
log emits a WARN about this — that's expected for local testing. See the env
|
||||||
|
vars table at the bottom if you need to lock it down.
|
||||||
|
|
||||||
|
> **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` /
|
||||||
|
> `:9102`?** Buzz binds three ports — main, health, metrics — and any of
|
||||||
|
> them can collide. Use a separate terminal per role and export the right
|
||||||
|
> vars in each:
|
||||||
|
>
|
||||||
|
> **In the relay terminal** (before launching `buzz-relay`):
|
||||||
|
> ```bash
|
||||||
|
> export BUZZ_BIND_ADDR=0.0.0.0:3030
|
||||||
|
> export BUZZ_HEALTH_PORT=8088
|
||||||
|
> export BUZZ_METRICS_PORT=9202
|
||||||
|
> export RELAY_URL=ws://localhost:3030 # advertised in NIP-42 challenges
|
||||||
|
> buzz-relay
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **In your working / CLI terminal** (for steps 4+ and the ACP harness):
|
||||||
|
> ```bash
|
||||||
|
> export BUZZ_RELAY_URL=http://localhost:3030 # CLI target
|
||||||
|
> # verify the relay on the overridden ports:
|
||||||
|
> curl -s http://localhost:3030/health # → ok
|
||||||
|
> curl -s http://localhost:8088/_readiness # → {"status":"ready"}
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Every snippet later in this doc shows the defaults. When you see
|
||||||
|
> `localhost:3000` / `:8080` in a code block, mentally substitute your
|
||||||
|
> overrides — or the CLI will end up talking to Buzz Desktop's relay.
|
||||||
|
|
||||||
|
> **Ignore `just setup`'s "Next steps" banner.** It still prints
|
||||||
|
> `just relay` (a debug build). Use `buzz-relay` from step 2 here —
|
||||||
|
> step 2 already built the release binary.
|
||||||
|
|
||||||
|
When you're done, stop the relay (Ctrl-C in its terminal). If it's
|
||||||
|
backgrounded or you lost the terminal: `pkill -f buzz-relay`. Leaving
|
||||||
|
it running will collide with the next reviewer who follows this doc on
|
||||||
|
the same machine.
|
||||||
|
|
||||||
|
### 4. Smoke test the CLI against the relay
|
||||||
|
|
||||||
|
End-to-end: generate an identity, create a channel, post a message, read it
|
||||||
|
back. This is the minimum sequence an agent needs to verify a local relay.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a keypair
|
||||||
|
GEN=$(buzz-admin generate-key)
|
||||||
|
export BUZZ_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
|
||||||
|
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
|
||||||
|
echo "pubkey: $PUBKEY"
|
||||||
|
|
||||||
|
# Create a channel — the UUID is returned in the response
|
||||||
|
CHANNEL=$(buzz channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
|
||||||
|
echo "channel: $CHANNEL"
|
||||||
|
|
||||||
|
# Send a message and read it back
|
||||||
|
SEND=$(buzz messages send --channel "$CHANNEL" --content "hello from smoke test")
|
||||||
|
EVENT_ID=$(echo "$SEND" | jq -r '.event_id')
|
||||||
|
buzz messages get --channel "$CHANNEL" --limit 5 | jq .
|
||||||
|
|
||||||
|
# Fetch the reply chain for a specific message (empty array on a leaf — that's fine)
|
||||||
|
buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful run prints `{"event_id":"…","accepted":true,"message":""}` for
|
||||||
|
the send, and the message body in the `get` output. `thread` returns `[]`
|
||||||
|
for a leaf message — populated only after a reply comes in (see §5).
|
||||||
|
|
||||||
|
### 5. Going deeper
|
||||||
|
|
||||||
|
For full coverage of every CLI command (54 subcommands across 12 groups),
|
||||||
|
follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md).
|
||||||
|
|
||||||
|
The relay's HTTP bridge accepts three endpoints — useful if you're testing
|
||||||
|
a client other than `buzz-cli`:
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|-----------------|------------------------------------|
|
||||||
|
| `POST /events` | Submit a signed Nostr event |
|
||||||
|
| `POST /query` | NIP-01 filter query (returns events) |
|
||||||
|
| `POST /count` | NIP-45 count query |
|
||||||
|
|
||||||
|
All three accept NIP-98 auth (recommended) or, in dev mode, an `X-Pubkey`
|
||||||
|
header fallback. There is no REST API for fetching message threads — use
|
||||||
|
`POST /query` with an `#e` filter, or `buzz messages thread`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACP Harness (optional, end-to-end with a real agent)
|
||||||
|
|
||||||
|
`buzz-acp` connects an ACP-speaking agent (goose, codex, claude code,
|
||||||
|
buzz-agent) to the relay. The harness listens for events, drives the
|
||||||
|
agent over stdio, and the agent replies through MCP tools.
|
||||||
|
|
||||||
|
Minimum recipe — assumes the relay from step 3 is running and the channel
|
||||||
|
`$CHANNEL` from step 4 still exists. The agent identity must be **different**
|
||||||
|
from the sender identity (`BUZZ_ACP_RESPOND_TO=anyone` still skips events
|
||||||
|
the agent signed itself).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release -p buzz-acp
|
||||||
|
export PATH="$PWD/target/release:$PATH"
|
||||||
|
|
||||||
|
# 1. Save your sender identity from step 4 — you'll need it to @mention the agent
|
||||||
|
SENDER_SK="$BUZZ_PRIVATE_KEY"
|
||||||
|
|
||||||
|
# 2. Mint a fresh agent identity and capture its pubkey
|
||||||
|
AGENT_GEN=$(buzz-admin generate-key)
|
||||||
|
AGENT_SK=$(echo "$AGENT_GEN" | awk '/Secret key:/ {print $3}')
|
||||||
|
AGENT_PUBKEY=$(echo "$AGENT_GEN" | awk '/Public key:/ {print $3}')
|
||||||
|
|
||||||
|
# 3. Add the agent as a member of $CHANNEL — still using the sender identity.
|
||||||
|
# Skip this and the agent boots to "discovered 0 channel(s) → agent will
|
||||||
|
# sit idle" and silently ignores every mention.
|
||||||
|
buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member
|
||||||
|
|
||||||
|
# 4. Switch to the agent identity and start it.
|
||||||
|
# buzz-acp wants ws:// (not http://). If you set BUZZ_RELAY_URL to an
|
||||||
|
# http:// URL in step 3, set the ws:// equivalent here — same host/port.
|
||||||
|
export BUZZ_PRIVATE_KEY="$AGENT_SK"
|
||||||
|
export BUZZ_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden)
|
||||||
|
export BUZZ_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing
|
||||||
|
# NIP-AE core-memory prompt injection is on by default; set BUZZ_ACP_NO_MEMORY=true to opt out.
|
||||||
|
export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts
|
||||||
|
|
||||||
|
buzz-acp # foreground; logs to stdout (run in a separate terminal)
|
||||||
|
|
||||||
|
# Optional: turn on per-turn tracing if the default log is too quiet.
|
||||||
|
# RUST_LOG=buzz_acp=debug buzz-acp
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Using a different ACP agent?** The default recipe assumes `goose` is on
|
||||||
|
> `$PATH` and configured (`goose --version` should print). For codex / claude
|
||||||
|
> code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS`
|
||||||
|
> accordingly — see `crates/buzz-acp/README.md`. Without these, buzz-acp
|
||||||
|
> will fail to spawn the agent subprocess on startup.
|
||||||
|
|
||||||
|
If you started the agent before adding it to the channel, just run the
|
||||||
|
`add-member` afterwards — it picks up the membership notification live and
|
||||||
|
subscribes without restart (`membership notification: subscribing to new channel …`).
|
||||||
|
|
||||||
|
The justfile also ships `just goose key="$AGENT_NSEC"` (foreground) and
|
||||||
|
`just goose-bg key="$AGENT_NSEC"` (background screen session) which set the
|
||||||
|
same env. See `crates/buzz-acp/README.md` for parallel agents, heartbeats,
|
||||||
|
respond-to gates, and forum subscriptions.
|
||||||
|
|
||||||
|
To exercise deferred ACP startup, add `BUZZ_ACP_LAZY_POOL=true` before launching
|
||||||
|
`buzz-acp`. The harness should connect, authenticate, subscribe, and publish
|
||||||
|
online presence without starting the configured ACP child. The first accepted,
|
||||||
|
flushable mention should start exactly one child and then dispatch the queued
|
||||||
|
message. Automated coverage in `pool_lifecycle_state` pins single-wake,
|
||||||
|
retry/backoff, and stale-result behavior; it does not replace this real
|
||||||
|
relay/process smoke test.
|
||||||
|
|
||||||
|
Send the agent a task — switch your shell back to the **sender** identity
|
||||||
|
from step 4 and @mention the agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BUZZ_PRIVATE_KEY=$SENDER_SK # the key from step 4
|
||||||
|
buzz messages send --channel "$CHANNEL" \
|
||||||
|
--content "Hey agent, reply PONG only."
|
||||||
|
|
||||||
|
# Wait 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_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. |
|
||||||
|
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
|
||||||
|
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
|
||||||
|
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
|
||||||
|
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
|
||||||
|
| `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works |
|
||||||
|
| `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag |
|
||||||
|
|
||||||
|
CLI-side, only two matter for testing:
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|-------------------------|--------------------------|-------|
|
||||||
|
| `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises |
|
||||||
|
| `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex |
|
||||||
|
| `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `relay error 500` or `400: restricted: not a channel member` after a code change | Stale binary | Rebuild and re-export `PATH`; or `cargo run` directly |
|
||||||
|
| `Address already in use` on relay start (os error 48 on macOS, 98 on Linux) | Another relay (or stale process) holding `:3000` / `:8080` / `:9102` (or your override ports) | The panic line names the failing port — read it first. Then `lsof -iTCP:3000,8080,9102 -sTCP:LISTEN` (or your override equivalents). Kill the offender (`pkill -f buzz-relay`) or use the port-override block in step 3. If you already overrode and *still* collide, a prior reviewer left a relay running on the same alt ports — kill it or pick fresh ports |
|
||||||
|
| `auth_error: BUZZ_PRIVATE_KEY is required` | Env not exported into the CLI's shell | `export BUZZ_PRIVATE_KEY=...` (or pass `--private-key`) |
|
||||||
|
| `auth_error: BUZZ_AUTH_TAG verification failed … signature verification failed` | A stale `BUZZ_AUTH_TAG` inherited from a parent shell. The local dev relay rejects it. | `unset BUZZ_AUTH_TAG` (see the scrub block in step 1) |
|
||||||
|
| `auth-required: verification failed` on a closed relay | NIP-OA attestation needed | Set `BUZZ_AUTH_TAG` to the owner-issued JSON, or relax `BUZZ_REQUIRE_RELAY_MEMBERSHIP` |
|
||||||
|
| `channels list` empty after `channels create` | The CLI doesn't echo the channel UUID; use the filter shown in step 4 | Or `POST /query` with `{"kinds":[39002]}` |
|
||||||
|
| ACP agent ignores all events | `BUZZ_ACP_RESPOND_TO=owner-only` (default) with no owner configured | Set `BUZZ_ACP_RESPOND_TO=anyone` for testing |
|
||||||
|
| ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity |
|
||||||
|
| `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` |
|
||||||
|
| Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) |
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# 🐝 Buzz — The relay is the workspace
|
||||||
|
|
||||||
|
> An engineer is debugging a production incident at 2am. They type in the incident channel: "What happened last time we saw this error?"
|
||||||
|
>
|
||||||
|
> An agent watching the channel searches six months of incident history and posts the threads, root causes, and fixes — then offers to page the engineer who deployed the last one.
|
||||||
|
|
||||||
|
The platform made it possible. The agent made it happen. Buzz is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Buzz gives them a shared space to use it.
|
||||||
|
|
||||||
|
One community is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Buzz app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. Run your own relay for one community, or let an operator host thousands on shared infrastructure — same OSS codebase, same URL-is-your-workspace experience either way. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Surface | Model | Default Notifications |
|
||||||
|
|---------|-------|-----------------------|
|
||||||
|
| 🏠 **Home** | Personalized feed. What matters to you. | — |
|
||||||
|
| 💬 **Stream** | Topic-based real-time chat. Work. | Zero |
|
||||||
|
| 📋 **Forum** | Async long-form threads. Culture. | Zero |
|
||||||
|
| ✉️ **DMs** | 1:1 and group. Up to 9. | URGENT only |
|
||||||
|
| 🤖 **Agents** | Directory. Your agents. Job board. | — |
|
||||||
|
| ⚡ **Workflows** | YAML-as-code automation. Traces. | Approvals only |
|
||||||
|
| 🔍 **Search** | Cmd+K. Instant. Full-text. | — |
|
||||||
|
|
||||||
|
*Desktop app supports all seven surfaces today.*
|
||||||
|
|
||||||
|
- **Stream** — Slack-like, fast. Mandatory topics → sub-replies. Zero-notification default.
|
||||||
|
- **Forum** — Discourse-like, slow. Post → flat replies. Zero-notification default.
|
||||||
|
- **Workflow** — Structured, traceable. Steps → approval gates. Approvals only.
|
||||||
|
|
||||||
|
One event log. One search index. Three lenses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
The relay enforces all access control. Channel membership is the only gate.
|
||||||
|
|
||||||
|
| Type | Visibility | Join | Create |
|
||||||
|
|------|-----------|------|--------|
|
||||||
|
| **Open channels** | Searchable by all members | Self-join | Any member |
|
||||||
|
| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member |
|
||||||
|
| **DMs** | Participants only | N/A (up to 9) | Any member |
|
||||||
|
| **Guests** | Scoped to specific channels | Invited | N/A |
|
||||||
|
|
||||||
|
Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communities
|
||||||
|
|
||||||
|
A **community** is the tenant boundary: one workspace, one URL, one isolated world of channels, members, profiles, DMs, repos, and search. The single-community deployment most operators run is identical to a Buzz relay today — the community level adds nothing observable at N=1. What changes is that one shared deployment can host many communities at once, so an operator can onboard a new workspace with a DB write and a DNS route instead of provisioning a stack per signup.
|
||||||
|
|
||||||
|
- **The URL is the community.** `myproject.com` is authoritative — exactly as a relay URL is today, lifted one level up. Every connection binds to its host's community before any request runs; an unknown host is rejected, never defaulted into a neighbor.
|
||||||
|
- **Isolation is the boundary, not a filter.** Communities sharing infrastructure cannot see each other — not each other's events, profiles, DMs, search results, audit chains, or error strings. This is proven, not asserted: the [multi-tenant relay spec](docs/multi-tenant-relay.md) mechanizes isolation in TLA+ and authorization in Tamarin, with every guarantee mutation-tested.
|
||||||
|
- **Identity is portable, profiles are per-community.** Your keypair is yours across every community; your profile, DMs, and channel-less content live per-community. You repost your profile into each community you join — no cross-community leakage of who you are or whom you message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Protocol
|
||||||
|
|
||||||
|
[Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) on the wire. Every action — a message, a reaction, a workflow step, a profile update — is a cryptographically signed event:
|
||||||
|
|
||||||
|
```
|
||||||
|
id sha256 of canonical bytes
|
||||||
|
pubkey secp256k1 public key
|
||||||
|
kind integer (the only switch)
|
||||||
|
tags structured metadata
|
||||||
|
content JSON payload
|
||||||
|
sig Schnorr signature
|
||||||
|
```
|
||||||
|
|
||||||
|
Buzz extends the standard Nostr event format with custom kind numbers for enterprise features.
|
||||||
|
|
||||||
|
New message type? New kind integer. Zero breaking changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Rust backend, TypeScript/React clients. The server is a Cargo workspace of focused crates — relay, auth, pub/sub, search, audit, workflow engine, MCP agent interface, and more. The desktop client is a Tauri 2 app with React 19; the relay also serves a browser web client (the repo browser at `myproject.com`). See [README.md](README.md) for the full crate map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
Humans and agents get the same thing:
|
||||||
|
|
||||||
|
- secp256k1 keypair (Nostr-native)
|
||||||
|
- `alice@example.com` NIP-05 handle
|
||||||
|
- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents)
|
||||||
|
- Bot role on agent channel membership. Visual badges are next.
|
||||||
|
|
||||||
|
Auth is simple — authenticated or not. Channel membership gates content visibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
One model. TLS in transit. At-rest encryption delegated to the storage layer (e.g., Postgres TDE, volume encryption). Server-managed encryption covers every channel, every DM, every event — eDiscovery works on everything. End-to-end encryption (NIP-44) is a future consideration for DMs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Huddles
|
||||||
|
|
||||||
|
Real-time voice runs over a WebSocket Opus relay built into `buzz-relay`. Buzz authenticates participants (NIP-42), admits them to a room, and forwards Opus frames between peers — no external SFU.
|
||||||
|
|
||||||
|
- Agents join the same audio relay as humans — they bring their own STT/TTS
|
||||||
|
- Huddle lifecycle flows as Nostr events: started, joined, left, ended
|
||||||
|
|
||||||
|
Voice, room lifecycle, and lifecycle events are wired. Recording and per-track publishing are planned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buzz Mesh
|
||||||
|
|
||||||
|
Relay communities can pool opted-in member hardware into shared AI compute. Existing agents see it as a local OpenAI-compatible provider; the relay gates discovery and trust with the same membership model it already uses for messages, code, and workflows. Models too large for any single machine split across several. See [VISION_MESH.md](VISION_MESH.md) for the full compute-commons vision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
Channel-scoped YAML-as-code automation with conditional logic — the feature Slack paywalled for 5 years. Message triggers, reaction triggers, scheduled runs, webhooks. Every step traced. Agents manage workflows through MCP tools.
|
||||||
|
|
||||||
|
Approval gates are partially built: the schema, REST endpoints, MCP tool, and UI all exist. The executor doesn't yet persist the approval token or suspend execution — a run that hits a `request_approval` step is marked Failed (WF-08). The infrastructure is there; the wiring is next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Home Feed & Notifications
|
||||||
|
|
||||||
|
Zero is the default. You opt in to noise, not out.
|
||||||
|
|
||||||
|
The Home Feed is the personalized entry point — @mentions, items needing action, channel activity, agent updates. Fan-out-on-read, assembled at query time. Agents read the same feed via MCP.
|
||||||
|
|
||||||
|
See [VISION_ACTIVITY.md](VISION_ACTIVITY.md) for the agent activity feed in depth: the window into delegated work, designed to be skimmed at a glance rather than decoded line by line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Features
|
||||||
|
|
||||||
|
Beyond chat: channels are workspaces.
|
||||||
|
|
||||||
|
- **Canvases** — a shared document per channel. Read and write via the desktop or MCP tools.
|
||||||
|
- **Media uploads** — paste, drop, or attach files. Stored via the [Blossom](https://github.com/hzrd149/blossom) protocol (BUD-01/BUD-02) on S3/MinIO. Thumbnails generated server-side.
|
||||||
|
- **Message editing and deletion** — with confirmation. Soft-deleted events remain in the audit log.
|
||||||
|
- **Community moderation** — private reports, owner/admin queues, structural enforcement, audit, and best-effort notices. See [VISION_MODERATION.md](VISION_MODERATION.md) for the full governance model.
|
||||||
|
- **Typing indicators** — real-time. Agents broadcast them too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
The relay hosts git repos. Smart HTTP — standard `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay.
|
||||||
|
|
||||||
|
Branches are channels. Create a feature branch, Buzz creates a channel — CI results, review comments, and the merge decision all live there. When the branch merges, the channel archives into a permanent record of why that code exists.
|
||||||
|
|
||||||
|
See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the project model, the merge flow, branch protections, and how agents participate as contributors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent CLI
|
||||||
|
|
||||||
|
`buzz-cli` is an agent-first CLI that mirrors and extends the MCP surface — same primitives, plus repo, upload, and canvas operations where the CLI is the canonical interface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Personas & Teams
|
||||||
|
|
||||||
|
Agents aren't monolithic. A persona bundles a model and a system prompt. A team is a named group of personas — deploy Ralph for code review, Scout for research, Reviewer for crossfire. Built-in personas ship with the desktop client; operators define their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote Agents
|
||||||
|
|
||||||
|
An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Culture Features
|
||||||
|
|
||||||
|
*(Planned design — not yet implemented)*
|
||||||
|
|
||||||
|
Not afterthoughts — ship blockers:
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| 🎨 Custom emoji | Tribal identity |
|
||||||
|
| 🎉 Confetti | On `/ship` |
|
||||||
|
| 📊 Native polls | `/poll`, first-class |
|
||||||
|
| ☕ Coffee Roulette | Weekly random human pairings |
|
||||||
|
| 🏆 Kudos | First-class recognition |
|
||||||
|
| 🧊 Knowledge Crystallization | AI proposes summaries, humans approve → pinned artifacts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scale
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| Users | 10K humans + 50K agents |
|
||||||
|
| Throughput | ~600K events/day (~7/sec avg) |
|
||||||
|
| Event store | Postgres 17, partitioned monthly |
|
||||||
|
| Fan-out | Redis pub/sub, <50ms p99 |
|
||||||
|
| Search | Postgres FTS, permission-aware, full-text |
|
||||||
|
| Audit | Hash-chain audit log, tamper-evident |
|
||||||
|
| Accessibility | WCAG 2.1 AA minimum |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Model
|
||||||
|
|
||||||
|
Greenfield. Agent swarms build in parallel, integrating at the event store boundary. Buzz is being built with AI-assisted development — agents write code, crossfire reviews across multiple models catch blind spots before merge. A complete platform, not a collection of independent microservices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| | Area |
|
||||||
|
|-|------|
|
||||||
|
| ✅ | Core relay, auth, pub/sub, search, audit |
|
||||||
|
| ✅ | MCP server — full feature surface |
|
||||||
|
| ✅ | ACP agent harness — goose, codex, claude code |
|
||||||
|
| ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence |
|
||||||
|
| ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete |
|
||||||
|
| ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers |
|
||||||
|
| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection |
|
||||||
|
| ✅ | Agent CLI — `buzz-cli`, mirrors and extends the MCP surface |
|
||||||
|
| ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined |
|
||||||
|
| 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) |
|
||||||
|
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
|
||||||
|
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
|
||||||
|
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
|
||||||
|
| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review |
|
||||||
|
| 📋 | Developer portal, push notifications, culture features |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [README.md](README.md) for setup and [AGENTS.md](AGENTS.md) for connecting AI agents. Licensed under Apache-2.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Buzz 🐝 — where humans and agents are just colleagues.*
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Vision: The Agent Activity Feed
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*.
|
||||||
|
|
||||||
|
We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line.
|
||||||
|
|
||||||
|
## Who It Serves
|
||||||
|
|
||||||
|
A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions:
|
||||||
|
|
||||||
|
- **Comprehension** — *what is it doing, and why?*
|
||||||
|
- **Confidence** — *is it going well, or is it stuck or wrong?*
|
||||||
|
- **Control** — *do I need to step in, and where?*
|
||||||
|
|
||||||
|
A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar.
|
||||||
|
|
||||||
|
## The Governing Frame: Verb, Object, Outcome
|
||||||
|
|
||||||
|
Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].**
|
||||||
|
|
||||||
|
> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed."
|
||||||
|
|
||||||
|
The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to.
|
||||||
|
|
||||||
|
## The Render Classes
|
||||||
|
|
||||||
|
Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry:
|
||||||
|
|
||||||
|
**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed.
|
||||||
|
|
||||||
|
**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign).
|
||||||
|
|
||||||
|
**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render).
|
||||||
|
|
||||||
|
These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract.
|
||||||
|
|
||||||
|
- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline.
|
||||||
|
|
||||||
|
- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines.
|
||||||
|
|
||||||
|
- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen.
|
||||||
|
|
||||||
|
- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed.
|
||||||
|
|
||||||
|
- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes.
|
||||||
|
|
||||||
|
- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace.
|
||||||
|
|
||||||
|
- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are.
|
||||||
|
|
||||||
|
- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed.
|
||||||
|
|
||||||
|
## What This Earns
|
||||||
|
|
||||||
|
The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible.
|
||||||
|
|
||||||
|
A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one.
|
||||||
|
|
||||||
|
Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Vision: buzz-agent + buzz-dev-mcp
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy.
|
||||||
|
|
||||||
|
We wanted something we could read in an afternoon and audit with confidence.
|
||||||
|
|
||||||
|
## What We Built
|
||||||
|
|
||||||
|
Two binaries, two protocols, no coupling between them.
|
||||||
|
|
||||||
|
**buzz-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, buzz-acp, or anything else that speaks ACP.
|
||||||
|
|
||||||
|
**buzz-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP.
|
||||||
|
|
||||||
|
Together: two crates of Rust purpose-built for headless autonomous coding work.
|
||||||
|
|
||||||
|
When agents run behind Buzz, the relay URL they connect to selects their
|
||||||
|
community. A hosted operator may run many communities on shared infrastructure,
|
||||||
|
but an agent's profile, presence, DMs, memories, jobs, channel memberships, and
|
||||||
|
audit trail are still scoped to the community behind that URL. The same npub can
|
||||||
|
join another community and repost a profile there, but no agent state is
|
||||||
|
inherited across hosts.
|
||||||
|
|
||||||
|
## Why We Built Our Own
|
||||||
|
|
||||||
|
**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short.
|
||||||
|
|
||||||
|
**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down.
|
||||||
|
|
||||||
|
**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind Buzz with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at buzz-agent and you get the same tool-calling behavior in your editor.
|
||||||
|
|
||||||
|
## The Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Any ACP client (Zed, JetBrains, buzz-acp, custom)
|
||||||
|
|
|
||||||
|
| stdio ACP (JSON-RPC 2.0)
|
||||||
|
v
|
||||||
|
buzz-agent (up to 8 concurrent sessions)
|
||||||
|
|
|
||||||
|
| stdio MCP (JSON-RPC 2.0) — one per session
|
||||||
|
v
|
||||||
|
buzz-dev-mcp (or any MCP server)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
shell, str_replace, todo; rg + tree on PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity.
|
||||||
|
|
||||||
|
- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes.
|
||||||
|
|
||||||
|
- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats.
|
||||||
|
|
||||||
|
- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops.
|
||||||
|
|
||||||
|
## What This Enables
|
||||||
|
|
||||||
|
- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8)
|
||||||
|
- Ten agents in parallel behind Buzz, each with their own MCP configuration
|
||||||
|
- The same agent key can participate in multiple Buzz communities while keeping membership, jobs, DMs, profile, and presence community-local
|
||||||
|
- Any ACP client gets a coding agent without a custom adapter
|
||||||
|
- Any MCP server gets a capable caller without a custom adapter
|
||||||
|
- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 🕸️ Buzz Mesh — Your community is your compute
|
||||||
|
|
||||||
|
> A small team runs their project on one Buzz relay. Three of them have GPUs that sit idle most of the day — a gaming PC, a laptop, a workstation under a desk. One flips a toggle: *Share compute.* The others point their agents at it. Now the whole team's coding agents answer from a capable model running on hardware they already own. No API keys. No cloud bill. Every prompt runs inside the relay community they already chose to trust.
|
||||||
|
|
||||||
|
A Buzz community is a trust group. The people in it already know each other — that shared membership is a decision they've already made. Buzz Mesh turns that decision into shared AI compute: the idle GPUs scattered across your community become one pool, usable by every agent in the community, gated by the membership you already have. And because the pool is many machines, not one, the community can run models larger and more capable than any one member could load alone. More intelligence becomes reachable when the group works as a group.
|
||||||
|
|
||||||
|
Nothing here is new on its own. Pooling GPUs across machines is solved. Nostr identity is solved. Community-gated membership is how Buzz already works. The insight is that the tool that pools the GPUs already speaks the same protocol Buzz is built on — so the mesh's admission gate and your community's membership gate are the same gate. The boundary is the community, never the deployment: a community on shared infrastructure pools only its own members' compute, and a co-tenant community can't find it, join it, or serve to it.
|
||||||
|
|
||||||
|
Each piece is boring. The combination is the thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You See
|
||||||
|
|
||||||
|
You open Buzz and flip on **Share compute.** Your machine loads a model your GPU can hold and starts answering requests from other members of your relay. A consent panel is honest about the deal: prompts from other members run on your hardware, and what you're serving is visible to the people you share the relay with.
|
||||||
|
|
||||||
|
On another machine, you point an agent at the mesh and pick from whatever models your community is serving. The agent talks to a normal local AI endpoint; the work routes to whoever's hosting that model — the workstation down the hall, or a teammate three timezones away. The agent neither knows nor cares which.
|
||||||
|
|
||||||
|
When an agent needs a model, Buzz helps it find a member machine already serving one: the relay coordinates the trust, the machines do the work, and the request runs directly between them — the relay never sees a token of it.
|
||||||
|
|
||||||
|
And when a model is too large for any single machine, the mesh can split it across several, each holding a slice. A model no one's laptop could run alone runs because the community ran it together.
|
||||||
|
|
||||||
|
Non-members see none of this. They can't find the mesh, can't join it, can't serve to it or use it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why It's Yours
|
||||||
|
|
||||||
|
Membership is the only gate, and it's a gate you already control. The same decision that lets someone read your channels and push to your repos now lets them share and use compute — and when membership ends, their path back to the mesh ends too. There is no separate access list to maintain, no new account, no new login. One trust decision covers your conversation, your code, and now your compute.
|
||||||
|
|
||||||
|
This is why it matters most for agents. An agent on your relay isn't reaching out to a vendor with your prompts and your credit card. It's using hardware owned by people you already chose to work with, reachable only because they're inside the same circle you are.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest Costs
|
||||||
|
|
||||||
|
Your prompts go to people, not a vendor. For a trust group that's a feature — far better than handing them to a stranger's cloud — but it is a different promise than "your data never leaves your machine," and the consent screen says so plainly. A community is only as private as its membership is trustworthy.
|
||||||
|
|
||||||
|
The mesh is opt-in, so it's only as capable as your community's participation. A relay where nobody shares compute has an empty mesh. That's the right default — you give willingly or not at all — but the value compounds with how many people turn it on.
|
||||||
|
|
||||||
|
These are honest costs. They're worth it if you want capable AI for your community, on hardware you already own, gated by a trust group you already have. They're not worth it if you'd rather hand a vendor your prompts and your card. Know which one you are.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Point
|
||||||
|
|
||||||
|
The relay is the workspace. Buzz Mesh makes it the compute commons too.
|
||||||
|
|
||||||
|
Your community is not just where agents talk, plan, and leave records. It is where they can run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Buzz 🐝 — your community is your compute.*
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 🛡️ Buzz Moderation — Your community, your rules
|
||||||
|
|
||||||
|
> Someone spams #general at midnight. A member taps **Report** — a category, an optional note, done. The report doesn't appear in anyone's feed; it lands in a queue only the community's owners and admins can see. In the morning an admin opens the queue, finds three reports against the same account, deletes the messages, and times the account out for a day. The room sees an honest marker where the spam was. The author gets a message explaining why. The reporter gets a message saying it was handled. Nobody else saw anything.
|
||||||
|
|
||||||
|
A Buzz community is a trust group with its own rules, and rules only matter if the people who own the room can enforce them. Buzz moderation gives community owners and admins the full loop: members report, the community's own owners and admins see and act, the relay enforces, and everyone affected hears the truth about what happened. The relay provides the mechanics — queue, authority, enforcement, audit, notices. The community provides the judgment. Buzz doesn't decide what your rules are; it makes sure you can actually have them.
|
||||||
|
|
||||||
|
Most of the nostr ecosystem treats moderation as **admission policy** — allow lists, block lists, a hook that rejects an event at the door. Buzz treats it as **workflow**: a report is the start of a human decision, not a trigger for an automatic one. That difference is the whole design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two Layers, Not One
|
||||||
|
|
||||||
|
Moderation splits the way it does on every serious platform:
|
||||||
|
|
||||||
|
**Community moderation** — subjective, per-community rule enforcement. Your owners and admins decide what's spam in *your* community, what crosses *your* line, who gets a second chance. This layer belongs to the community and never reaches past it: an admin's authority ends at the community boundary, structurally, because every moderation decision is scoped to the tenant it was made in.
|
||||||
|
|
||||||
|
**Platform safety** — the severe class: illegal content, network-level abuse, legal reporting obligations. That is never delegated to community admins. A community owner or admin can **escalate** a report upward, and the escalation is recorded durably for the platform operator's safety process. The community layer is the front line; the platform layer is the backstop.
|
||||||
|
|
||||||
|
This document is about the first layer. The second has its own lane.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You See
|
||||||
|
|
||||||
|
**As a member**, every message has a Report action. Pick why — spam, profanity, illegal content, impersonation, or another supported reason — add context if you want, send. Your report is private: it is never broadcast, never stored as a public event, never visible to the person you reported. It goes to the people who can act on it, and only them.
|
||||||
|
|
||||||
|
**As an owner or admin**, you have a queue. Reports arrive grouped by target, newest first, with the reporter's identity visible to you — accountability runs both ways — and never to the reported author. From the queue you act in one motion: dismiss, delete the message, kick, timeout, ban, or escalate. Reasons travel where they should — to the audit trail, to the tombstone, to the restricted user — without exposing private report context to the room.
|
||||||
|
|
||||||
|
**As the room**, a removed message leaves an honest tombstone — "removed by a community moderator," with a sanitized reason — instead of a silent hole. The room learns that the rules are real without republishing the offense.
|
||||||
|
|
||||||
|
**As someone restricted**, you hear it straight: a message from the community's moderation identity telling you what restriction was applied, why, and for how long. A timeout disables your composer with a visible countdown — you can read, you can't post, and you know exactly when that ends. No silent write-drops, no shadow bans, no guessing.
|
||||||
|
|
||||||
|
**As the reporter**, you hear the outcome. The loop closes. Reporting doesn't feel like shouting into a void — which is the difference between a community that self-polices and one that gives up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Mechanics That Matter
|
||||||
|
|
||||||
|
- **Reports are signals, never triggers.** No user report auto-removes anything. Reports are gameable; human judgment is the gate. The queue aggregates and an owner or admin decides.
|
||||||
|
|
||||||
|
- **Reports are private structural state.** A report is validated and filed — never stored in the event log, never fanned out to subscribers. Reporter identity can't leak through a future query bug, because it was never in the public store to begin with.
|
||||||
|
|
||||||
|
- **Moderation actions are signed commands.** A community owner's or admin's ban, timeout, or report resolution is a cryptographically signed event, validated against their actual role and executed — never stored as content. Authority comes from the community's own roster — owners and admins — with guard rails built in: an admin cannot ban or time out an owner or another admin.
|
||||||
|
|
||||||
|
- **Enforcement lives at the identity seam.** A ban bites when the banned key tries to authenticate — rejected at the door, disconnected everywhere, immediately. A timeout is a write-block with a stated expiry. Enforcement isn't scattered through the codebase as filters; it happens where identity is established, which is why it can't be sidestepped.
|
||||||
|
|
||||||
|
- **The important decisions are audited.** Bans, timeouts, report dismissals, escalations, and report resolutions write durable audit rows — who, what, whom, why, when — with the decision recorded separately from its enforcement, so the trail never claims something happened that didn't. Message removals also leave visible tombstones for the room. The full report record (including reporter identity and notes) stays moderator-only; the public sees only the sanitized reason.
|
||||||
|
|
||||||
|
- **The wire uses nostr where nostr has the right primitive.** Reports are NIP-56. Group roles and membership actions are NIP-29. Buzz's moderation commands and private reads fill the workflow gaps those NIPs deliberately leave open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest Edges
|
||||||
|
|
||||||
|
**Escalation is a hook today, not a pipeline.** Escalating writes a durable, queryable record for the platform operator — but the platform-side inbox that consumes it is a separate build. The substrate is there; the tooling above it comes next.
|
||||||
|
|
||||||
|
**Two roles, not three.** Owners and admins moderate. There is no volunteer-moderator tier yet — deliberately. Authority is structured as capabilities, so adding a moderator tier later is a policy change, not a rewrite. We'd rather ship a loop that works and grow the org chart when communities ask for it.
|
||||||
|
|
||||||
|
**Notices are best-effort.** The DMs that close the loop never block enforcement — a ban lands even if the notice fails. Enforcement is the promise; notification is the courtesy. A later platform-escalation pass should also make escalated reports say exactly that, instead of reusing the generic handled message.
|
||||||
|
|
||||||
|
**No automod.** Nothing scans content before it posts. Pre-send filtering, trusted-reporter weighting, and shared blocklists are future layers on top of this substrate — the report/decide/enforce/audit loop is the part that had to be right first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Point
|
||||||
|
|
||||||
|
A community you can't moderate isn't yours — it just has your name on it. The relay is the workspace; moderation is what makes it *governable* by the people it belongs to. Judgment stays human. Enforcement is structural. Everyone affected hears the truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Buzz 🐝 — your community, your rules.*
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# 🐝 Buzz Projects — A Nostr-Native Forge
|
||||||
|
|
||||||
|
> Someone pushes a fix. Buzz creates a channel for the branch. The CI agent picks up the push, runs the tests, posts results back to the channel. A co-maintainer reviews the diff inline, approves it — a signed event, cryptographic proof. Merge. The workflow runs the integration. The channel archives into a permanent record of why that code exists.
|
||||||
|
>
|
||||||
|
> Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread.
|
||||||
|
|
||||||
|
This document is the software-forge slice of the broader Buzz platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. In multi-community Buzz, the same rule is lifted one level up: a project domain or subdomain selects the community first, and repositories, workflows, approvals, Blossom artifacts, and git ref updates under that host are community-local even if an operator runs many communities on shared backend infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Project Model
|
||||||
|
|
||||||
|
A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences.
|
||||||
|
|
||||||
|
Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. The host in the clone/push URL is also the community selector: the same `owner/repo` name may exist in two communities without sharing refs, branch protections, workflow runs, approvals, or repo announcements.
|
||||||
|
|
||||||
|
The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Buzz extends it with `buzz-` prefixed tags for channel binding and visibility:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30617,
|
||||||
|
"tags": [
|
||||||
|
["d", "buzz"],
|
||||||
|
["name", "buzz"],
|
||||||
|
["clone", "https://repoa.myproject.com"],
|
||||||
|
["relays", "wss://myproject.com"],
|
||||||
|
["maintainers", "<co-maintainer-npub>"],
|
||||||
|
["buzz-channel", "<channel-uuid>"],
|
||||||
|
["buzz-visibility", "listed"],
|
||||||
|
["buzz-protect", "main", "push-allowed", "<alice-npub>", "<bob-npub>"],
|
||||||
|
["buzz-protect", "main", "require-approval", "2"],
|
||||||
|
["buzz-protect", "main", "no-force-push"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch protections live in the same event — `buzz-protect` tags. The relay enforces them at the git transport layer. Only npubs listed in `push-allowed` can push to protected branches. Force pushes are blocked. Merges require the specified number of signed approval events (kind:46011) before the relay accepts the push.
|
||||||
|
|
||||||
|
Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
|
||||||
|
|
||||||
|
Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, no custom kind for the repo itself.
|
||||||
|
|
||||||
|
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One Project, Many Repos
|
||||||
|
|
||||||
|
Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things.
|
||||||
|
|
||||||
|
Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind.
|
||||||
|
|
||||||
|
Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control.
|
||||||
|
|
||||||
|
So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30621,
|
||||||
|
"tags": [
|
||||||
|
["d", "platform"],
|
||||||
|
["name", "Platform"],
|
||||||
|
["a", "30617:<alice-npub-hex>:buzz"],
|
||||||
|
["a", "30617:<bob-npub-hex>:buzz-infra"],
|
||||||
|
["buzz-channel", "<channel-uuid>"],
|
||||||
|
["buzz-visibility", "listed"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's.
|
||||||
|
|
||||||
|
The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branches as Channels
|
||||||
|
|
||||||
|
A feature branch is a conversation.
|
||||||
|
|
||||||
|
When you create a branch, Buzz creates a channel. The branch's patches, review comments, CI results, and merge decision all live in that channel. When the branch merges, the channel archives. The conversation becomes the permanent record of why that code exists.
|
||||||
|
|
||||||
|
```
|
||||||
|
#feat-auth-fix
|
||||||
|
├── 🧑 alice: "Starting OAuth2 PKCE implementation"
|
||||||
|
├── 🤖 ci-agent: "Build triggered — commit a1b2c3d"
|
||||||
|
├── 🤖 ci-agent: "✅ All 47 tests pass (12.3s)"
|
||||||
|
├── 📎 kind:1617 patch — src/auth/pkce.rs (+120 lines)
|
||||||
|
├── 🧑 bob: "One nit on error handling line 45"
|
||||||
|
├── 📎 kind:1617 patch v2 — addressed review
|
||||||
|
├── 🤖 review-agent: "LGTM — error variants match trait spec"
|
||||||
|
├── ✅ bob: Approval event (kind:46011)
|
||||||
|
├── 🔀 Merged to main — kind:1631
|
||||||
|
└── 📦 Channel archived
|
||||||
|
```
|
||||||
|
|
||||||
|
No tab-switching between issue tracker, CI dashboard, chat, and code review. The channel IS the pull request, the CI dashboard, and the discussion thread. One stream. One search index.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Merge Flow
|
||||||
|
|
||||||
|
Push to merge, fully traced. Every step is a signed event.
|
||||||
|
|
||||||
|
```
|
||||||
|
Push CI Review Merge
|
||||||
|
│ │ │ │
|
||||||
|
│ kind:30618 │ │ │
|
||||||
|
│ (ref update) │ │
|
||||||
|
│───────────►│ │ │
|
||||||
|
│ │ Workflow │ │
|
||||||
|
│ │ triggers │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Build ✅ │ │
|
||||||
|
│ │ Test ✅ │ │
|
||||||
|
│ │ Lint ✅ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ kind:1630 ────►│ │
|
||||||
|
│ │ (CI passed) │ │
|
||||||
|
│ │ │ Review in │
|
||||||
|
│ │ │ branch channel│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ kind:46011 │
|
||||||
|
│ │ │ (approved) ──►│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │ Merge to main
|
||||||
|
│ │ │ │ kind:1631
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │ Channel archives
|
||||||
|
```
|
||||||
|
|
||||||
|
The approval event is signed by the maintainer's npub. The merge status references the approval. The audit log chains them together. Cryptographic proof of who approved what.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Web of Trust
|
||||||
|
|
||||||
|
Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. Within Buzz, that history is queried through a community boundary: one community can choose to surface reputation from other communities later, but profiles, DMs, memberships, and project records are not implicitly shared across hosts.
|
||||||
|
|
||||||
|
A new contributor submits a patch. Before you read the code:
|
||||||
|
|
||||||
|
1. **Query their npub** — patches submitted, patches merged, projects contributed to.
|
||||||
|
2. **Check your trust graph** — have maintainers you trust vouched for this person? Signed approval events are public and queryable.
|
||||||
|
3. **Assess risk** — fresh npub with no history gets scrutiny. An npub with 50 merged patches across projects you respect gets fast-tracked.
|
||||||
|
|
||||||
|
This works because identity is cryptographic and portable. Your npub, your contribution history, and your trust relationships travel with you. No platform owns your reputation.
|
||||||
|
|
||||||
|
**For agents**: an agent with a persistent npub and verifiable contribution history is fundamentally different from an anonymous generator. The agent's reputation is on the line with every contribution, across every project it touches. See [NIP-OA](docs/nips/NIP-OA.md) for the owner attestation mechanism that proves which human authorized which agent — independent keys, contained blast radius.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI and Workflows
|
||||||
|
|
||||||
|
Workflows orchestrate. Agents perform the compute. The relay is the message bus, not the build server.
|
||||||
|
|
||||||
|
A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation.
|
||||||
|
|
||||||
|
Workflows live in the repo (`.buzz/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. Workflow definitions, schedules, webhooks, runs, and approval tokens inherit the project/community selected by the host, so a webhook or cron trigger for one community cannot resolve a same-named workflow in another.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
trigger:
|
||||||
|
on: diff_posted
|
||||||
|
steps:
|
||||||
|
- id: build
|
||||||
|
action: call_webhook
|
||||||
|
url: "https://ci.internal/build"
|
||||||
|
body: '{"commit": "{{trigger.commit}}"}'
|
||||||
|
- id: test
|
||||||
|
action: call_webhook
|
||||||
|
url: "https://ci.internal/test"
|
||||||
|
if: "steps.build.output.status == 'success'"
|
||||||
|
- id: gate
|
||||||
|
action: request_approval
|
||||||
|
message: "CI passed. Approve merge?"
|
||||||
|
if: "steps.test.output.status == 'success'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Every step traced. Every trace a signed event. Change the project CI once and every branch gets it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues, Docs, Releases
|
||||||
|
|
||||||
|
### Issues → Forum + NIP-34
|
||||||
|
|
||||||
|
Bug reports are NIP-34 kind:1621 events, rendered through Buzz's forum surface. Threaded comments use NIP-22 kind:1111. Labels, assignees, milestones are nostr tags. Design discussions and RFCs use the forum's long-form async surface.
|
||||||
|
|
||||||
|
NIP-34 clients can discover and interact with issues. Buzz's forum gives them a home with threading, search, and agent triage.
|
||||||
|
|
||||||
|
### Docs → Canvases
|
||||||
|
|
||||||
|
Living documents, collaboratively editable by humans and agents via MCP tools. Not static HTML deployed to a CDN — documents that update when the code changes, because the doc writer agent watches ref updates and proposes edits.
|
||||||
|
|
||||||
|
### Releases → Agent + Workflow
|
||||||
|
|
||||||
|
An agent in `#releases` watches `main`. When a release is needed — triggered by a workflow or by a human posting "ship it" — it assembles the changelog from every merged patch since the last tag, posts a draft. The maintainer approves. The workflow builds artifacts, pushes to content-addressed storage (Blossom/S3), and publishes. Logged, signed, traceable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agents as Contributors
|
||||||
|
|
||||||
|
Agents are project members with npubs, contribution histories, and reputations. The protocol treats them identically to humans. Visual badges distinguish them in the UI.
|
||||||
|
|
||||||
|
| | Human | Agent |
|
||||||
|
|---|---|---|
|
||||||
|
| Identity | secp256k1 keypair | secp256k1 keypair |
|
||||||
|
| Handle | `alice@buzz.dev` | `triage-bot@buzz.dev` |
|
||||||
|
| Events | Signed with npub | Signed with npub |
|
||||||
|
| History | On the relay | On the relay |
|
||||||
|
| Reputation | Earned by contributions | Earned by contributions |
|
||||||
|
|
||||||
|
| Role | Watches | Does |
|
||||||
|
|------|---------|------|
|
||||||
|
| **Triage** | Issues (kind:1621) | Labels, assigns, detects duplicates, pre-screens |
|
||||||
|
| **Review** | Patches (kind:1617) | First-pass code review, style checks, dependency audit |
|
||||||
|
| **Docs** | Ref updates (kind:30618) | Keeps docs in sync after merges |
|
||||||
|
| **Merge coordinator** | CI results | Runs the merge train, requests human sign-off |
|
||||||
|
| **Coding agent** | Jobs (kind:43001) | Implements tasks, submits patches for review |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nostr-Native
|
||||||
|
|
||||||
|
Standard kinds as substrate. Custom kinds only where genuinely novel.
|
||||||
|
|
||||||
|
| Layer | Standard NIP Kinds | Buzz Custom | Rationale |
|
||||||
|
|-------|-------------------|---------------|-----------|
|
||||||
|
| **Git state** | 30617, 30618, 1617, 1618, 1621, 1630-1633 (NIP-34) | — | Interop with ngit, gitworkshop.dev |
|
||||||
|
| **Comments** | 1111 (NIP-22) | — | Threaded replies everywhere |
|
||||||
|
| **Channels** | 9000-9022, 39000-39003 (NIP-29) | — | Project workspaces |
|
||||||
|
| **HTTP auth** | 27235 (NIP-98) | — | Git push authentication |
|
||||||
|
| **Agent identity** | 0 (NIP-01 profile) | — | Agents are npubs |
|
||||||
|
| **Artifacts** | 1063 (NIP-94) | — | Build outputs on Blossom/S3 |
|
||||||
|
| **Workflows** | — | 46001-46012 | No NIP equivalent |
|
||||||
|
| **Job dispatch** | — | 43001-43006 | Delegation trees |
|
||||||
|
| **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility |
|
||||||
|
| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags |
|
||||||
|
| **Audit** | — | 48001 | Hash-chain tamper-evident log |
|
||||||
|
|
||||||
|
If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Capability | Status |
|
||||||
|
|---|---|
|
||||||
|
| Channels, forums, DMs, canvases | ✅ Ships today |
|
||||||
|
| Workflow engine (triggers, traces, conditional logic) | ✅ Ships today |
|
||||||
|
| MCP server + ACP agent harness | ✅ Ships today |
|
||||||
|
| Blossom media storage (SHA-256, S3) | ✅ Ships today |
|
||||||
|
| Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
|
||||||
|
| Project binding (kind:30617 + `buzz-` tags) | 📋 Designed |
|
||||||
|
| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 Designed |
|
||||||
|
| Git hosting (smart HTTP + NIP-34) | ✅ Ships today |
|
||||||
|
| Merge coordinator | 📋 Designed |
|
||||||
|
| NIP-34 issues (kind:1621) | 📋 Designed |
|
||||||
|
| Web-of-trust reputation | 📋 Designed |
|
||||||
|
|
||||||
|
The collaboration platform is built, and git hosting ships today — `git clone`/`git push` over smart HTTP with NIP-34 manifests. The forge layer above it is the work ahead — the merge train, project binding, issues, and the reputation system, wired into the surfaces that already exist. See [VISION.md](VISION.md) for the platform and [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the sovereign relay story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Buzz 🐝 — the forge where identity is the foundation.*
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 🛰️ Buzz Remote Agents — Same agent, new body
|
||||||
|
|
||||||
|
> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation.
|
||||||
|
|
||||||
|
An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working.
|
||||||
|
|
||||||
|
Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Same Agent, New Body
|
||||||
|
|
||||||
|
What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine.
|
||||||
|
|
||||||
|
So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Only Tether
|
||||||
|
|
||||||
|
Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate.
|
||||||
|
|
||||||
|
Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance.
|
||||||
|
|
||||||
|
This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bodies Are Replaceable
|
||||||
|
|
||||||
|
Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately.
|
||||||
|
|
||||||
|
Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)).
|
||||||
|
|
||||||
|
The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agents That Know When to Leave
|
||||||
|
|
||||||
|
The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing.
|
||||||
|
|
||||||
|
Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest Costs
|
||||||
|
|
||||||
|
**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work.
|
||||||
|
|
||||||
|
**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide.
|
||||||
|
|
||||||
|
**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did.
|
||||||
|
|
||||||
|
**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought.
|
||||||
|
|
||||||
|
**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there.
|
||||||
|
|
||||||
|
**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one.
|
||||||
|
|
||||||
|
**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu.
|
||||||
|
|
||||||
|
These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Point
|
||||||
|
|
||||||
|
The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Buzz 🐝 — your agent, everywhere.*
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Buzz — Your Project, Your Domain
|
||||||
|
|
||||||
|
`myproject.com` is your workspace. Not a GitHub org page that happens to have your
|
||||||
|
name on it. Not a Discord server that Discord could delete tomorrow. Your domain.
|
||||||
|
Your relay. One thing.
|
||||||
|
|
||||||
|
The relay is the workspace. Code lives there. Conversation lives there. Agents connect
|
||||||
|
to it. Automation flows through it. Artifacts publish from it. You type the URL, you
|
||||||
|
see the workspace. That's the whole idea.
|
||||||
|
|
||||||
|
Nothing here is novel on its own. Git hosting is solved. Chat is solved. Agents are
|
||||||
|
solved. Nostr identity is solved. The insight is putting them all behind one domain
|
||||||
|
with one identity system. Each piece is boring. The combination is the thing.
|
||||||
|
|
||||||
|
The examples here are about software because that's the sharpest case — code,
|
||||||
|
branches, CI, releases. But the model works for anything a group builds together.
|
||||||
|
Research, hardware, writing, governance. The relay doesn't care what's in the
|
||||||
|
channels or the repos. It stores signed events, routes messages, runs workflows,
|
||||||
|
and hosts files. What you build with that is up to you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You See
|
||||||
|
|
||||||
|
You type `myproject.com`. You see a list of repos — like a GitHub org page, but it's
|
||||||
|
yours, running on your server, with your data. You click one. You're at
|
||||||
|
`repoa.myproject.com`. The README renders. The file tree is there. Click into `src/`
|
||||||
|
and the code is syntax-highlighted. The clone URL sits at the top:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone repoa.myproject.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Next to it, a "Connect on Buzz" button.
|
||||||
|
|
||||||
|
No separate website. No static site generator, no deploy step, no "pages" concept.
|
||||||
|
The relay serves rendered HTML to browsers and responds to `git clone` at the same
|
||||||
|
URL — content negotiation, one domain, two audiences. The repo *is* the website.
|
||||||
|
A browser gets a rendered page. A git client gets a git server. Same URL.
|
||||||
|
|
||||||
|
You clone it. It works. No special tooling. No new protocol. Git is git. You push
|
||||||
|
the same way. Agents clone and push the same way. The repos are repos.
|
||||||
|
|
||||||
|
You hit "Connect on Buzz" and the app opens. You're in `#general`. The team is
|
||||||
|
talking. There's a forum with open issues and design discussions. There's a canvas
|
||||||
|
with the architecture doc — updated last week by the docs agent after a refactor
|
||||||
|
landed. Three feature branches are active, each one a channel. You're in.
|
||||||
|
Everything is here.
|
||||||
|
|
||||||
|
A new contributor finds the project the same way. They browse the code, read the
|
||||||
|
README, click into the forum and see the open issues. They read a design doc in the
|
||||||
|
canvas. They understand the project before they write a line of code. Then they hit
|
||||||
|
"Connect on Buzz" and they're in the community. No separate onboarding flow. No
|
||||||
|
"read our wiki at wiki.myproject.com and our forum at forum.myproject.com and our
|
||||||
|
chat at discord.gg/..." Everything is at `myproject.com`. That's where everything
|
||||||
|
is.
|
||||||
|
|
||||||
|
Not every project needs to run its own relay. Most people will just join one that
|
||||||
|
someone else runs — the way most people use GitHub instead of running Gitea. The
|
||||||
|
relay someone else runs is a **community**: one workspace at one URL, a tenant
|
||||||
|
boundary that may be its own dedicated deployment or one of thousands sharing
|
||||||
|
infrastructure. Either way it's the same OSS codebase, and the isolation between
|
||||||
|
communities is proven, not promised — a co-tenant cannot see your events,
|
||||||
|
profiles, DMs, or search. Your key stays yours across all of them; identity is
|
||||||
|
portable even when the hosting isn't. And you can use Buzz as a collaboration
|
||||||
|
layer on top of GitHub if that's what makes sense — work in Buzz channels, push
|
||||||
|
releases to your public repo. The sovereign setup is the full version. But the
|
||||||
|
tools work at every level of commitment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Work Happens
|
||||||
|
|
||||||
|
A contributor files a bug. It lands in the forum. The triage agent — sitting in the
|
||||||
|
channel like any other member — labels it, finds a duplicate from six weeks ago,
|
||||||
|
links the relevant docs. This happens before a human reads it. The contributor gets
|
||||||
|
a response in seconds. The maintainer sees a pre-processed report, not a raw inbox.
|
||||||
|
They read the triage summary, confirm it's real, assign it. Thirty seconds of their
|
||||||
|
time instead of five minutes.
|
||||||
|
|
||||||
|
Someone picks up the bug. They create a branch. Buzz creates a channel:
|
||||||
|
`#feat-auth-fix`. They work there. They push commits. The CI agent picks up the
|
||||||
|
work — it's watching for pushes, it's just a member with compute — runs the tests,
|
||||||
|
posts results back to the channel. Green. The patch lands in the channel as a
|
||||||
|
reviewable diff. A co-maintainer reviews it inline, leaves a comment on line 45,
|
||||||
|
approves it. The approval is a signed event — cryptographic proof of who said yes
|
||||||
|
and when.
|
||||||
|
|
||||||
|
Merge. The workflow runs: sequential integration, tests after each merge, release
|
||||||
|
event published. The channel archives. The conversation is now the permanent record
|
||||||
|
of why that code exists. Not a chat thread that scrolled away. Not a PR that might
|
||||||
|
get deleted. The event log, on your relay, in your database. Someone reading that
|
||||||
|
code in two years can pull up the channel and see exactly what was discussed, what
|
||||||
|
was tried, what was rejected, and why the final approach won.
|
||||||
|
|
||||||
|
The whole flow — bug report to merged patch — happened in one place. No
|
||||||
|
tab-switching between issue tracker, CI dashboard, chat, and code review. One
|
||||||
|
stream. One URL. One search index. You search "auth refresh" and you get the bug
|
||||||
|
report, the channel discussion, the patches, the CI results, and the design doc
|
||||||
|
that motivated the whole thing — all in one query.
|
||||||
|
|
||||||
|
**The release flow** is where it clicks. An agent in `#releases` watches `main`.
|
||||||
|
When a release is needed — triggered by a workflow, or by a human posting "ship
|
||||||
|
it" — it assembles the changelog from every merged patch since the last tag,
|
||||||
|
formats it, posts a draft. The maintainer edits two lines, approves. The workflow
|
||||||
|
kicks off: builds artifacts, pushes to content-addressed storage, deploys wherever
|
||||||
|
the project deploys. The result posts back to `#releases`. Done. The whole thing
|
||||||
|
is logged, signed, and traceable.
|
||||||
|
|
||||||
|
The release agent isn't special infrastructure. It's an npub with compute that
|
||||||
|
happens to be fast. Same for the CI agent, the triage agent, the docs agent. They
|
||||||
|
sit in channels. They watch for things. They clone repos and push code. They get
|
||||||
|
pinged by workflows or by humans. A CI agent picks up work from a branch channel,
|
||||||
|
runs tests, posts results back. A docs agent watches for merges and proposes doc
|
||||||
|
updates. A release agent watches `main` and ships. They're not a separate system.
|
||||||
|
They're contributors.
|
||||||
|
|
||||||
|
Workflows are the connective tissue. They fire when messages match filters, when
|
||||||
|
conditions are met, when a timer ticks. They don't execute work — they coordinate
|
||||||
|
it. The relay is the message bus. Agents are the workers. They run wherever they
|
||||||
|
run: your server, a cloud function, a laptop. The workflow just knows who to ping
|
||||||
|
and when. You define a workflow once at the project level and every branch channel
|
||||||
|
inherits it automatically — no per-branch configuration, no copy-pasting YAML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Social Layer
|
||||||
|
|
||||||
|
Not everything belongs in the repo. Not everything belongs in a channel. Some
|
||||||
|
things are announcements. Some things are essays. Buzz has surfaces for both,
|
||||||
|
and they live on the same relay as the code.
|
||||||
|
|
||||||
|
Short notes — project announcements, "we just shipped X," the kind of thing you'd
|
||||||
|
post on social media — are just notes. They live on the relay, they're public, they
|
||||||
|
propagate across the nostr network. Someone following your project sees them in
|
||||||
|
their feed. No separate social media account to maintain. No "follow us on Twitter"
|
||||||
|
link pointing somewhere else. Your relay is your social presence. When you ship a
|
||||||
|
release, you post a note. It goes out to everyone following the project's npub.
|
||||||
|
That's it.
|
||||||
|
|
||||||
|
Long-form posts are for the thinking that doesn't fit anywhere else. The design
|
||||||
|
decision that deserves more than a channel message. The research write-up explaining
|
||||||
|
why you chose a particular approach. The post-mortem on that outage. The RFC that
|
||||||
|
shaped the architecture. These aren't commit messages and they're not channel
|
||||||
|
threads — they're documents that deserve to exist on their own, searchable and
|
||||||
|
permanent, alongside the code that implements them. Years later, someone reading
|
||||||
|
the code can find the reasoning. It's on the same relay. Same search. Same
|
||||||
|
identity. The blog post and the code it describes are in the same place.
|
||||||
|
|
||||||
|
Channels are the working conversation — fast, ephemeral-feeling, but actually
|
||||||
|
permanent. The repo is the code and docs that belong with the code. Notes are the
|
||||||
|
public face. Long-form is the thinking. Each surface has its job. Nothing bleeds
|
||||||
|
into the wrong place.
|
||||||
|
|
||||||
|
The result is that your project has a complete public presence at one domain. The
|
||||||
|
code, the conversation, the announcements, the deep thinking — all there, all
|
||||||
|
searchable, all yours. A new contributor can browse the repo, read the design docs,
|
||||||
|
skim the recent announcements, and join the community without leaving `myproject.com`.
|
||||||
|
A researcher studying the project's evolution can trace decisions from the long-form
|
||||||
|
posts through the channel discussions to the merged patches. It's all connected
|
||||||
|
because it's all on the same relay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
Your npub is your identity everywhere. One keypair. No accounts to create on each
|
||||||
|
new platform. No "sign in with GitHub" that means GitHub owns your identity. Your
|
||||||
|
keys, your identity.
|
||||||
|
|
||||||
|
Reputation follows you across projects. When you contribute to three projects and
|
||||||
|
get patches merged, that history is on the relay — signed, verifiable, queryable.
|
||||||
|
When you show up at a fourth project, your track record shows up with you. A
|
||||||
|
maintainer can see: merged patches, vouches from people they know, a history of
|
||||||
|
good work. That's not a number a platform assigned you. It's your actual history,
|
||||||
|
cryptographically attested. You can't fake it. You can't buy it. You earn it by
|
||||||
|
doing the work.
|
||||||
|
|
||||||
|
Web-of-trust emerges naturally. Who vouches for whom. Who merges whose patches.
|
||||||
|
Who has a track record. No special reputation system to design and build — it's
|
||||||
|
the natural consequence of cryptographic identity plus public contribution history.
|
||||||
|
A maintainer you trust vouches for a contributor you've never heard of. That vouch
|
||||||
|
is a signed event. You can see it. You can weight it. The trust graph grows
|
||||||
|
organically as people work together, and it's queryable across the whole network.
|
||||||
|
|
||||||
|
This matters especially for agents. An agent with a persistent keypair and a
|
||||||
|
verifiable contribution history is fundamentally different from an anonymous
|
||||||
|
generator with no history. The agent has skin in the game. Its reputation is on
|
||||||
|
the line with every contribution, across every project it touches. Bad work
|
||||||
|
degrades its standing everywhere, not just in one repo. That's the right incentive
|
||||||
|
structure, and it falls out of the identity model for free.
|
||||||
|
|
||||||
|
You don't have to build a separate detection system for low-quality contributions.
|
||||||
|
You look at the npub's history. A fresh keypair with no history and no vouches gets
|
||||||
|
scrutiny. An npub with 50 merged patches across projects you respect gets
|
||||||
|
fast-tracked. The signal is in the history. The web-of-trust does the filtering.
|
||||||
|
|
||||||
|
No platform owns your identity. No platform can suspend your account. Your keypair
|
||||||
|
is yours. If you move to a different relay, your identity comes with you. Your
|
||||||
|
contribution history is portable. The work you did is yours. If `myproject.com`
|
||||||
|
goes down, your npub still exists. Your history still exists. You stand up a new
|
||||||
|
relay, point your domain at it, and you're back. The project continues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What You Give Up
|
||||||
|
|
||||||
|
You run infrastructure. A server, a domain, a relay. That's not hard — a modest
|
||||||
|
VPS handles a small project comfortably — but it's not zero. Someone has to keep
|
||||||
|
it running. Someone has to handle backups. Someone has to deal with the 3 AM alert
|
||||||
|
when the disk fills up. Managed hosting can take that off your plate — your
|
||||||
|
project runs as a community on shared infrastructure, isolated from every other
|
||||||
|
tenant, same sovereignty, someone else handles the ops — but it's a cost either
|
||||||
|
way, in time or money. Worth knowing before you start.
|
||||||
|
|
||||||
|
Key management is harder than "sign in with Google." Losing your private key means
|
||||||
|
losing your identity. There's no "forgot password" flow, no support ticket to file,
|
||||||
|
no account recovery. Hardware keys help. Good practices help. But it's a real
|
||||||
|
tradeoff and you should go in knowing it. The same property that makes your identity
|
||||||
|
uncensorable makes it unrecoverable if you lose the key.
|
||||||
|
|
||||||
|
The ecosystem is young. The tooling is good and getting better, but it's not a
|
||||||
|
decade of polish. Some things will feel rough. Some integrations won't exist yet.
|
||||||
|
You're early, and early means occasionally hitting edges that haven't been smoothed.
|
||||||
|
|
||||||
|
Your contributors are early too. Most developers don't have a nostr keypair.
|
||||||
|
Onboarding friction is real — not insurmountable, but real. The "Connect on Buzz"
|
||||||
|
button is easy. Explaining what a keypair is takes a sentence. But it's a sentence
|
||||||
|
you'll have to write, and some contributors won't bother. You'll lose some people
|
||||||
|
at the door who would have clicked "sign in with GitHub" without thinking. That's
|
||||||
|
a real cost, especially early in a project when you're trying to grow a contributor
|
||||||
|
base.
|
||||||
|
|
||||||
|
These are honest costs. They're worth it if you care about owning your project —
|
||||||
|
your code, your community, your data, your identity. They're not worth it if you
|
||||||
|
just want the path of least resistance. Know which one you are.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Point
|
||||||
|
|
||||||
|
One domain. One identity. Everything in one place — and it's yours.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hans">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>Buzz 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "buzz-admin-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"check": "biome check . && pnpm typecheck && pnpm test",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"test": "vitest run src --passWithNoTests",
|
||||||
|
"test:e2e": "pnpm build && playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"jsdom": "^27.3.0",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vitest": "^4.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm exec vite preview --host 127.0.0.1 --port 4174",
|
||||||
|
url: "http://127.0.0.1:4174",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
},
|
||||||
|
use: { baseURL: "http://127.0.0.1:4174" },
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466 309">
|
||||||
|
<style>
|
||||||
|
.mark { fill: #231e1e; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.mark { fill: #d7d72e; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<mask id="bee-mask">
|
||||||
|
<circle cx="91.7" cy="154.5" r="91.7" fill="white"/>
|
||||||
|
<circle cx="374.3" cy="154.5" r="91.7" fill="white"/>
|
||||||
|
<rect x="128" y="0" width="210" height="309" rx="34" fill="white"/>
|
||||||
|
<circle cx="193.3" cy="84.4" r="27" fill="black"/>
|
||||||
|
<circle cx="276" cy="84.4" r="27" fill="black"/>
|
||||||
|
<rect x="166.3" y="157.2" width="136.9" height="38.3" rx="5" fill="black"/>
|
||||||
|
<rect x="166.9" y="235.1" width="136.2" height="37.6" rx="5" fill="black"/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect class="mark" width="466" height="309" mask="url(#bee-mask)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,917 @@
|
|||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { ApiFailure, request } from "./api";
|
||||||
|
import { useI18n, type MessageKey } from "./i18n";
|
||||||
|
import type {
|
||||||
|
FeedbackDetail,
|
||||||
|
FeedbackSummary,
|
||||||
|
Report,
|
||||||
|
ReportDetail as ReportDetailData,
|
||||||
|
} from "./types";
|
||||||
|
import { useResource } from "./useResource";
|
||||||
|
|
||||||
|
function usePath() {
|
||||||
|
const [path, setPath] = useState(location.pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setPath(location.pathname);
|
||||||
|
addEventListener("popstate", update);
|
||||||
|
return () => removeEventListener("popstate", update);
|
||||||
|
}, []);
|
||||||
|
const navigate = useCallback((url: string) => {
|
||||||
|
history.pushState(null, "", url);
|
||||||
|
dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
}, []);
|
||||||
|
return { path, navigate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Link({
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
activeWhenNested = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
activeWhenNested?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { path, navigate } = usePath();
|
||||||
|
const active =
|
||||||
|
path === href || (activeWhenNested && path.startsWith(`${href}/`));
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!event.metaKey && !event.ctrlKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateView<T>({
|
||||||
|
resource,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
resource: ReturnType<typeof useResource<T>>;
|
||||||
|
children: (data: T) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (resource.loading && !resource.data)
|
||||||
|
return <div className="state">{t("loading")}</div>;
|
||||||
|
if (resource.error && !resource.data) {
|
||||||
|
const forbidden =
|
||||||
|
resource.error instanceof ApiFailure && resource.error.status === 403;
|
||||||
|
return (
|
||||||
|
<div className="state error" role="alert">
|
||||||
|
<h2>{forbidden ? t("accessDenied") : t("couldNotLoadData")}</h2>
|
||||||
|
<p>{t("requestFailed")}</p>
|
||||||
|
<button type="button" onClick={resource.refetch}>
|
||||||
|
{t("retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resource.data ? children(resource.data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reports() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const resource = useResource(
|
||||||
|
() => request<Report[]>("/reports?status=open&limit=100"),
|
||||||
|
"reports",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
eyebrow={t("moderation")}
|
||||||
|
title={t("openReports")}
|
||||||
|
description={t("reportsDescription")}
|
||||||
|
>
|
||||||
|
<StateView resource={resource}>
|
||||||
|
{(reports) =>
|
||||||
|
reports.length ? (
|
||||||
|
<div className="cards">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<Link
|
||||||
|
key={report.id}
|
||||||
|
href={`/reports/${report.id}`}
|
||||||
|
className="card-link"
|
||||||
|
>
|
||||||
|
<article className="record-card">
|
||||||
|
<span className="record-icon report-icon">
|
||||||
|
<ReportIcon />
|
||||||
|
</span>
|
||||||
|
<div className="record-primary">
|
||||||
|
<span className="tag">
|
||||||
|
{dataLabel(t, report.reportType, reportTypeLabels)}
|
||||||
|
</span>
|
||||||
|
<strong>{report.communityHost}</strong>
|
||||||
|
<code>
|
||||||
|
{dataLabel(t, report.targetKind, targetKindLabels)}:{" "}
|
||||||
|
{short(report.target)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="record-date">
|
||||||
|
<span>{t("submitted")}</span>
|
||||||
|
<time>
|
||||||
|
{date(report.createdAt, locale, t("unknownDate"))}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<ArrowIcon />
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</StateView>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDetail({ id }: { id: string }) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const resource = useResource(
|
||||||
|
() => request<ReportDetailData>(`/reports/${id}`),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
eyebrow={t("moderation")}
|
||||||
|
title={t("reportDetail")}
|
||||||
|
description={t("reportDetailDescription")}
|
||||||
|
back="/reports"
|
||||||
|
>
|
||||||
|
<StateView resource={resource}>
|
||||||
|
{(report) => (
|
||||||
|
<article className="detail">
|
||||||
|
<div className="detail-heading">
|
||||||
|
<span className="record-icon report-icon">
|
||||||
|
<ReportIcon />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="tag">
|
||||||
|
{dataLabel(t, report.reportType, reportTypeLabels)}
|
||||||
|
</span>
|
||||||
|
<h2>{report.communityHost}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>{t("status")}</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="status">
|
||||||
|
{dataLabel(t, report.status, reportStatusLabels)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dt>{t("reporter")}</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{report.reporterPubkey}</code>
|
||||||
|
</dd>
|
||||||
|
<dt>{t("target")}</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{report.target}</code>
|
||||||
|
</dd>
|
||||||
|
{report.targetKind === "event" ? (
|
||||||
|
<>
|
||||||
|
<dt>{t("message")}</dt>
|
||||||
|
<dd>
|
||||||
|
{report.message ? (
|
||||||
|
<div className="reported-message">
|
||||||
|
{report.message.deletedAt ? (
|
||||||
|
<span className="status">{t("deleted")}</span>
|
||||||
|
) : null}
|
||||||
|
<p>{report.message.content}</p>
|
||||||
|
<div className="reported-message-meta">
|
||||||
|
<span>{t("author")}</span>
|
||||||
|
<code>{report.message.authorPubkey}</code>
|
||||||
|
<span>{t("created")}</span>
|
||||||
|
<time>
|
||||||
|
{date(
|
||||||
|
report.message.createdAt,
|
||||||
|
locale,
|
||||||
|
t("unknownDate"),
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="message-unavailable">
|
||||||
|
{t("messageUnavailable")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<dt>{t("note")}</dt>
|
||||||
|
<dd className="sensitive">{report.note ?? t("noNote")}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</StateView>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackList() {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const resource = useResource(
|
||||||
|
() => request<FeedbackSummary[]>("/feedback"),
|
||||||
|
"feedback",
|
||||||
|
);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [community, setCommunity] = useState("all");
|
||||||
|
const [timeRange, setTimeRange] = useState("all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [statuses, setStatuses] = useState(loadFeedbackStatuses);
|
||||||
|
|
||||||
|
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const checked = event.target.checked;
|
||||||
|
setStatuses((current) => {
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
[id]: checked,
|
||||||
|
};
|
||||||
|
saveFeedbackStatuses(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
eyebrow={t("product")}
|
||||||
|
title={t("feedback")}
|
||||||
|
description={t("feedbackDescription")}
|
||||||
|
>
|
||||||
|
<StateView resource={resource}>
|
||||||
|
{(items) => {
|
||||||
|
if (!items.length) return <Empty />;
|
||||||
|
return (
|
||||||
|
<FeedbackResults
|
||||||
|
items={items}
|
||||||
|
query={query}
|
||||||
|
community={community}
|
||||||
|
timeRange={timeRange}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
statuses={statuses}
|
||||||
|
>
|
||||||
|
{({ communities, filtered }) => (
|
||||||
|
<>
|
||||||
|
<div className="feedback-filters">
|
||||||
|
<label className="search-field">
|
||||||
|
<span>{t("searchFeedback")}</span>
|
||||||
|
<div>
|
||||||
|
<SearchIcon />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={t("searchFeedback")}
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("community")}</span>
|
||||||
|
<select
|
||||||
|
value={community}
|
||||||
|
onChange={(event) => setCommunity(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("allCommunities")}</option>
|
||||||
|
{communities.map((host) => (
|
||||||
|
<option key={host} value={host}>
|
||||||
|
{host}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("received")}</span>
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(event) => setTimeRange(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">{t("anyTime")}</option>
|
||||||
|
<option value="day">{t("last24Hours")}</option>
|
||||||
|
<option value="week">{t("last7Days")}</option>
|
||||||
|
<option value="month">{t("last30Days")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("status")}</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStatusFilter(event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">{t("anyStatus")}</option>
|
||||||
|
<option value="pending">{t("needsAction")}</option>
|
||||||
|
<option value="acted-on">{t("actedOn")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="result-count" aria-live="polite">
|
||||||
|
{t("submissionsCount", {
|
||||||
|
filtered: filtered.length,
|
||||||
|
total: items.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{filtered.length ? (
|
||||||
|
<div className="cards">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id}
|
||||||
|
className="record-card feedback-card feedback-record"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/feedback/${item.id}`}
|
||||||
|
className="feedback-main-link"
|
||||||
|
>
|
||||||
|
<span className="record-icon feedback-icon">
|
||||||
|
<CategoryIcon category={item.category} />
|
||||||
|
</span>
|
||||||
|
<div className="record-primary">
|
||||||
|
<CategoryTag category={item.category} />
|
||||||
|
<strong>{item.bodySummary}</strong>
|
||||||
|
<span className="record-provenance">
|
||||||
|
{item.communityHost}
|
||||||
|
<code>{short(item.submitterPubkey)}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<label className="feedback-status">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={statuses[item.id] ?? false}
|
||||||
|
onChange={(event) => updateStatus(item.id, event)}
|
||||||
|
/>
|
||||||
|
{t("actedOn")}
|
||||||
|
<span className="visually-hidden">
|
||||||
|
{t("feedback")} · {item.communityHost}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="record-date">
|
||||||
|
<span>{t("received")}</span>
|
||||||
|
<time>
|
||||||
|
{date(item.receivedAt, locale, t("unknownDate"))}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/feedback/${item.id}`}
|
||||||
|
className="record-open-link"
|
||||||
|
>
|
||||||
|
<span className="visually-hidden">
|
||||||
|
{t("feedback")} · {item.communityHost}
|
||||||
|
</span>
|
||||||
|
<ArrowIcon />
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="state">{t("noMatchingFeedback")}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FeedbackResults>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</StateView>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackResults({
|
||||||
|
items,
|
||||||
|
query,
|
||||||
|
community,
|
||||||
|
timeRange,
|
||||||
|
statusFilter,
|
||||||
|
statuses,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
items: FeedbackSummary[];
|
||||||
|
query: string;
|
||||||
|
community: string;
|
||||||
|
timeRange: string;
|
||||||
|
statusFilter: string;
|
||||||
|
statuses: FeedbackStatuses;
|
||||||
|
children: (results: {
|
||||||
|
communities: string[];
|
||||||
|
filtered: FeedbackSummary[];
|
||||||
|
}) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const communities = [...new Set(items.map((item) => item.communityHost))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
const after = timeRangeStart(timeRange);
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
if (community !== "all" && item.communityHost !== community) return false;
|
||||||
|
if (statusFilter === "pending" && statuses[item.id]) return false;
|
||||||
|
if (statusFilter === "acted-on" && !statuses[item.id]) return false;
|
||||||
|
if (after !== undefined) {
|
||||||
|
const receivedAt = new Date(item.receivedAt).valueOf();
|
||||||
|
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
|
||||||
|
}
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return [
|
||||||
|
item.bodySummary,
|
||||||
|
item.communityHost,
|
||||||
|
item.category ?? "uncategorized",
|
||||||
|
item.submitterPubkey,
|
||||||
|
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
|
||||||
|
});
|
||||||
|
return { communities, filtered };
|
||||||
|
}, [items, query, community, timeRange, statusFilter, statuses]);
|
||||||
|
return children(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackDetailView({ id }: { id: string }) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const resource = useResource(
|
||||||
|
() => request<FeedbackDetail>(`/feedback/${id}`),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
eyebrow={t("product")}
|
||||||
|
title={t("feedbackDetail")}
|
||||||
|
description={t("feedbackDetailDescription")}
|
||||||
|
back="/feedback"
|
||||||
|
backLabel={t("backToFeedback")}
|
||||||
|
>
|
||||||
|
<StateView resource={resource}>
|
||||||
|
{(feedback) => {
|
||||||
|
const attachments = feedbackAttachments(
|
||||||
|
feedback.id,
|
||||||
|
feedback.tags,
|
||||||
|
feedback.communityHost,
|
||||||
|
);
|
||||||
|
const body = stripAttachmentMarkdown(feedback.body, attachments);
|
||||||
|
return (
|
||||||
|
<article className="detail">
|
||||||
|
<div className="detail-heading">
|
||||||
|
<span className="record-icon feedback-icon">
|
||||||
|
<CategoryIcon category={feedback.category} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<CategoryTag category={feedback.category} />
|
||||||
|
<h2>{feedback.communityHost}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>{t("feedback")}</dt>
|
||||||
|
<dd className="sensitive feedback-body">{body}</dd>
|
||||||
|
{attachments.length ? (
|
||||||
|
<>
|
||||||
|
<dt>{t("attachments")}</dt>
|
||||||
|
<dd className="attachments">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<Attachment
|
||||||
|
key={`${attachment.hash}-${attachment.url}`}
|
||||||
|
attachment={attachment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<dt>{t("submittedBy")}</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{feedback.submitterPubkey}</code>
|
||||||
|
</dd>
|
||||||
|
<dt>{t("event")}</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{feedback.eventId}</code>
|
||||||
|
</dd>
|
||||||
|
<dt>{t("created")}</dt>
|
||||||
|
<dd>
|
||||||
|
{date(feedback.eventCreatedAt, locale, t("unknownDate"))}
|
||||||
|
</dd>
|
||||||
|
<dt>{t("received")}</dt>
|
||||||
|
<dd>{date(feedback.receivedAt, locale, t("unknownDate"))}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</StateView>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedbackStatuses = Record<string, boolean>;
|
||||||
|
|
||||||
|
interface FeedbackAttachment {
|
||||||
|
url: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
hash: string;
|
||||||
|
size?: number;
|
||||||
|
dimensions?: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status";
|
||||||
|
|
||||||
|
function loadFeedbackStatuses(): FeedbackStatuses {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(FEEDBACK_STATUS_KEY);
|
||||||
|
return stored ? (JSON.parse(stored) as FeedbackStatuses) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFeedbackStatuses(statuses: FeedbackStatuses) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses));
|
||||||
|
} catch {
|
||||||
|
// The controls remain useful for the current session if storage is blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRangeStart(range: string) {
|
||||||
|
const durations: Record<string, number> = {
|
||||||
|
day: 24 * 60 * 60 * 1000,
|
||||||
|
week: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
month: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
const duration = durations[range];
|
||||||
|
return duration ? Date.now() - duration : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackAttachments(
|
||||||
|
feedbackId: string,
|
||||||
|
tags: string[][],
|
||||||
|
communityHost: string,
|
||||||
|
): FeedbackAttachment[] {
|
||||||
|
return tags.flatMap((tag) => {
|
||||||
|
if (tag[0] !== "imeta") return [];
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
for (const entry of tag.slice(1)) {
|
||||||
|
const separator = entry.indexOf(" ");
|
||||||
|
if (separator > 0) {
|
||||||
|
values.set(entry.slice(0, separator), entry.slice(separator + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = values.get("url");
|
||||||
|
const mimeType = values.get("m");
|
||||||
|
const hash = values.get("x");
|
||||||
|
const safeUrl = url && safeAttachmentUrl(url, communityHost);
|
||||||
|
if (!safeUrl || !mimeType || !hash) return [];
|
||||||
|
const parsedSize = Number(values.get("size"));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
|
||||||
|
sourceUrl: safeUrl,
|
||||||
|
mimeType,
|
||||||
|
hash,
|
||||||
|
size:
|
||||||
|
Number.isFinite(parsedSize) && parsedSize > 0
|
||||||
|
? parsedSize
|
||||||
|
: undefined,
|
||||||
|
dimensions: values.get("dim"),
|
||||||
|
filename: values.get("filename"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAttachmentUrl(value: string, communityHost: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value, `${location.protocol}//${communityHost}`);
|
||||||
|
return ["http:", "https:"].includes(url.protocol) &&
|
||||||
|
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
|
||||||
|
url.pathname.startsWith("/media/")
|
||||||
|
? url.href
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAttachmentMarkdown(
|
||||||
|
body: string,
|
||||||
|
attachments?: FeedbackAttachment[],
|
||||||
|
) {
|
||||||
|
const knownUrls = attachments
|
||||||
|
? new Set(attachments.map((attachment) => attachment.sourceUrl))
|
||||||
|
: undefined;
|
||||||
|
return body
|
||||||
|
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
|
||||||
|
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
|
||||||
|
return isMedia ? "" : match;
|
||||||
|
})
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
|
||||||
|
const url = attachment.url;
|
||||||
|
const name =
|
||||||
|
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
|
||||||
|
const metadata = [
|
||||||
|
attachment.mimeType,
|
||||||
|
attachment.dimensions,
|
||||||
|
attachment.size ? formatBytes(attachment.size) : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
if (attachment.mimeType.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<figure className="image-attachment">
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<img src={url} alt={name} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<figcaption>
|
||||||
|
<span>{name}</span>
|
||||||
|
<small>{metadata}</small>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="file-attachment"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={name}
|
||||||
|
>
|
||||||
|
<FileIcon />
|
||||||
|
<span>
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<small>{metadata}</small>
|
||||||
|
</span>
|
||||||
|
<ArrowIcon />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Page({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
back,
|
||||||
|
backLabel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
back?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page-title">
|
||||||
|
{back ? (
|
||||||
|
<Link href={back} className="back-link">
|
||||||
|
<ArrowIcon /> {backLabel ?? t("backToReports")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<p>{eyebrow}</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<span>{description}</span>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <div className="state">{t("noRecords")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function short(value: string) {
|
||||||
|
return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function date(value: string, locale: string, unknownDate: string) {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.valueOf())
|
||||||
|
? unknownDate
|
||||||
|
: new Intl.DateTimeFormat(locale === "zh-Hans" ? "zh-CN" : "en-US", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuzzMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 466 309" aria-hidden="true">
|
||||||
|
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
|
||||||
|
<path d="M12 7.5v5M12 16.5h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
|
||||||
|
<path d="M8.5 9h7M8.5 12h4.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryTag({ category }: { category?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
bug: t("categoryBug"),
|
||||||
|
praise: t("categoryPraise"),
|
||||||
|
"needs-work": t("categoryNeedsWork"),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className="tag">
|
||||||
|
<CategoryIcon category={category} />
|
||||||
|
{category ? (labels[category] ?? category) : t("uncategorized")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportTypeLabels: Record<string, MessageKey> = {
|
||||||
|
spam: "reportSpam",
|
||||||
|
illegal: "reportIllegal",
|
||||||
|
nudity: "reportNudity",
|
||||||
|
malware: "reportMalware",
|
||||||
|
impersonation: "reportImpersonation",
|
||||||
|
profanity: "reportProfanity",
|
||||||
|
other: "reportOther",
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportStatusLabels: Record<string, MessageKey> = {
|
||||||
|
open: "statusOpen",
|
||||||
|
resolved: "statusResolved",
|
||||||
|
dismissed: "statusDismissed",
|
||||||
|
escalated: "statusEscalated",
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetKindLabels: Record<string, MessageKey> = {
|
||||||
|
event: "targetEvent",
|
||||||
|
pubkey: "targetPubkey",
|
||||||
|
blob: "targetBlob",
|
||||||
|
};
|
||||||
|
|
||||||
|
function dataLabel(
|
||||||
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
|
value: string,
|
||||||
|
labels: Record<string, MessageKey>,
|
||||||
|
) {
|
||||||
|
const key = labels[value];
|
||||||
|
return key ? t(key) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryIcon({ category }: { category?: string }) {
|
||||||
|
if (category === "bug") return <BugIcon />;
|
||||||
|
if (category === "praise") return <ThumbsUpIcon />;
|
||||||
|
if (category === "needs-work") return <WrenchIcon />;
|
||||||
|
return <FeedbackIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BugIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 20v-9" />
|
||||||
|
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||||
|
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThumbsUpIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||||
|
<path d="M7 10v12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WrenchIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 3h8l4 4v14H6V3Z" />
|
||||||
|
<path d="M14 3v5h4M9 13h6M9 17h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { path } = usePath();
|
||||||
|
const { locale, setLocale, t } = useI18n();
|
||||||
|
const report = path.match(/^\/reports\/([^/]+)$/);
|
||||||
|
const feedback = path.match(/^\/feedback\/([^/]+)$/);
|
||||||
|
const content = report ? (
|
||||||
|
<ReportDetail id={report[1]} />
|
||||||
|
) : feedback ? (
|
||||||
|
<FeedbackDetailView id={feedback[1]} />
|
||||||
|
) : path === "/feedback" ? (
|
||||||
|
<FeedbackList />
|
||||||
|
) : (
|
||||||
|
<Reports />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<Link href="/reports" className="brand">
|
||||||
|
<span className="brand-mark">
|
||||||
|
<BuzzMark />
|
||||||
|
</span>
|
||||||
|
<span>{t("buzzAdmin")}</span>
|
||||||
|
</Link>
|
||||||
|
<nav>
|
||||||
|
<Link href="/reports" className="nav-link" activeWhenNested>
|
||||||
|
<ReportIcon /> {t("reports")}
|
||||||
|
</Link>
|
||||||
|
<Link href="/feedback" className="nav-link" activeWhenNested>
|
||||||
|
<FeedbackIcon /> {t("feedback")}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<fieldset className="locale-switcher">
|
||||||
|
<legend className="visually-hidden">{t("localeSwitch")}</legend>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={locale === "zh-Hans" ? "active" : undefined}
|
||||||
|
aria-pressed={locale === "zh-Hans"}
|
||||||
|
onClick={() => setLocale("zh-Hans")}
|
||||||
|
>
|
||||||
|
{t("localeChinese")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={locale === "en" ? "active" : undefined}
|
||||||
|
aria-pressed={locale === "en"}
|
||||||
|
onClick={() => setLocale("en")}
|
||||||
|
>
|
||||||
|
{t("localeEnglish")}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</header>
|
||||||
|
<main>{content}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const PREFIX = "/api/admin/v1";
|
||||||
|
|
||||||
|
export class ApiFailure extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${PREFIX}${path}`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const envelope = await response.json().catch(() => null);
|
||||||
|
throw new ApiFailure(
|
||||||
|
response.status,
|
||||||
|
envelope?.error?.message ?? `Request failed (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { messages, type Locale, type MessageKey } from "./messages";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "buzz-admin-locale";
|
||||||
|
const DEFAULT_LOCALE: Locale = "zh-Hans";
|
||||||
|
|
||||||
|
interface I18nValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: MessageKey, values?: Record<string, string | number>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nValue | null>(null);
|
||||||
|
|
||||||
|
function readLocale(): Locale {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "en" || stored === "zh-Hans") return stored;
|
||||||
|
} catch {
|
||||||
|
// Storage may be disabled; the default remains usable.
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(
|
||||||
|
template: string,
|
||||||
|
values?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
if (!values) return template;
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) =>
|
||||||
|
String(values[key] ?? `{{${key}}}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(readLocale);
|
||||||
|
const setLocale = useCallback((next: Locale) => {
|
||||||
|
setLocaleState(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Keep the current session usable when storage is blocked.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const t = useCallback(
|
||||||
|
(key: MessageKey, values?: Record<string, string | number>) =>
|
||||||
|
interpolate(messages[locale][key] ?? messages.en[key], values),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
document.title = t("appTitle");
|
||||||
|
}, [locale, t]);
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ locale, setLocale, t }),
|
||||||
|
[locale, setLocale, t],
|
||||||
|
);
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nValue {
|
||||||
|
const value = useContext(I18nContext);
|
||||||
|
if (!value) throw new Error("useI18n must be used inside I18nProvider");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_LOCALE, STORAGE_KEY };
|
||||||
|
export type { Locale, MessageKey } from "./messages";
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
export const en = {
|
||||||
|
appTitle: "Buzz admin",
|
||||||
|
localeName: "English",
|
||||||
|
localeSwitch: "Language",
|
||||||
|
localeEnglish: "English",
|
||||||
|
localeChinese: "简体中文",
|
||||||
|
loading: "Loading…",
|
||||||
|
accessDenied: "Access denied",
|
||||||
|
couldNotLoadData: "Could not load data",
|
||||||
|
retry: "Retry",
|
||||||
|
requestFailed: "The request failed. Please try again.",
|
||||||
|
moderation: "Moderation",
|
||||||
|
openReports: "Open reports",
|
||||||
|
reportsDescription: "Review reports across every Buzz community.",
|
||||||
|
submitted: "Submitted",
|
||||||
|
reportDetail: "Report detail",
|
||||||
|
reportDetailDescription: "The full report as submitted to the relay.",
|
||||||
|
status: "Status",
|
||||||
|
reporter: "Reporter",
|
||||||
|
target: "Target",
|
||||||
|
message: "Message",
|
||||||
|
deleted: "Deleted",
|
||||||
|
author: "Author",
|
||||||
|
created: "Created",
|
||||||
|
messageUnavailable:
|
||||||
|
"Message content is unavailable. It may have expired or been removed from event storage.",
|
||||||
|
note: "Note",
|
||||||
|
noNote: "No note provided.",
|
||||||
|
product: "Product",
|
||||||
|
feedback: "Feedback",
|
||||||
|
feedbackDescription: "Recent product feedback from across Buzz.",
|
||||||
|
searchFeedback: "Search feedback",
|
||||||
|
community: "Community",
|
||||||
|
allCommunities: "All communities",
|
||||||
|
received: "Received",
|
||||||
|
anyTime: "Any time",
|
||||||
|
last24Hours: "Last 24 hours",
|
||||||
|
last7Days: "Last 7 days",
|
||||||
|
last30Days: "Last 30 days",
|
||||||
|
anyStatus: "Any status",
|
||||||
|
needsAction: "Needs action",
|
||||||
|
actedOn: "Acted on",
|
||||||
|
submissionsCount: "{{filtered}} of {{total}} submissions",
|
||||||
|
noMatchingFeedback: "No matching feedback.",
|
||||||
|
feedbackDetail: "Feedback detail",
|
||||||
|
feedbackDetailDescription: "The complete feedback submission and its source.",
|
||||||
|
backToFeedback: "Back to feedback",
|
||||||
|
backToReports: "Back to reports",
|
||||||
|
attachments: "Attachments",
|
||||||
|
submittedBy: "Submitted by",
|
||||||
|
event: "Event",
|
||||||
|
noRecords: "No records.",
|
||||||
|
unknownDate: "Unknown date",
|
||||||
|
uncategorized: "Uncategorized",
|
||||||
|
categoryBug: "Bug",
|
||||||
|
categoryPraise: "Praise",
|
||||||
|
categoryNeedsWork: "Needs work",
|
||||||
|
buzzAdmin: "Buzz Admin",
|
||||||
|
reports: "Reports",
|
||||||
|
reportSpam: "Spam",
|
||||||
|
reportIllegal: "Illegal content",
|
||||||
|
reportNudity: "Nudity",
|
||||||
|
reportMalware: "Malware",
|
||||||
|
reportImpersonation: "Impersonation",
|
||||||
|
reportProfanity: "Profanity",
|
||||||
|
reportOther: "Other",
|
||||||
|
statusOpen: "Open",
|
||||||
|
statusResolved: "Resolved",
|
||||||
|
statusDismissed: "Dismissed",
|
||||||
|
statusEscalated: "Escalated",
|
||||||
|
targetEvent: "Event",
|
||||||
|
targetPubkey: "Public key",
|
||||||
|
targetBlob: "File",
|
||||||
|
languageUpdated: "Language updated",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const zhHans: Record<keyof typeof en, string> = {
|
||||||
|
appTitle: "Buzz 管理后台",
|
||||||
|
localeName: "简体中文",
|
||||||
|
localeSwitch: "语言",
|
||||||
|
localeEnglish: "English",
|
||||||
|
localeChinese: "简体中文",
|
||||||
|
loading: "加载中…",
|
||||||
|
accessDenied: "无权访问",
|
||||||
|
couldNotLoadData: "无法加载数据",
|
||||||
|
retry: "重试",
|
||||||
|
requestFailed: "请求失败,请稍后重试。",
|
||||||
|
moderation: "内容审核",
|
||||||
|
openReports: "待处理举报",
|
||||||
|
reportsDescription: "查看所有 Buzz 社区的举报。",
|
||||||
|
submitted: "提交时间",
|
||||||
|
reportDetail: "举报详情",
|
||||||
|
reportDetailDescription: "查看提交到 relay 的完整举报内容。",
|
||||||
|
status: "状态",
|
||||||
|
reporter: "举报人",
|
||||||
|
target: "目标",
|
||||||
|
message: "消息",
|
||||||
|
deleted: "已删除",
|
||||||
|
author: "作者",
|
||||||
|
created: "创建时间",
|
||||||
|
messageUnavailable: "消息内容不可用,可能已过期或已从事件存储中移除。",
|
||||||
|
note: "备注",
|
||||||
|
noNote: "未提供备注。",
|
||||||
|
product: "产品",
|
||||||
|
feedback: "反馈",
|
||||||
|
feedbackDescription: "查看来自 Buzz 各社区的近期产品反馈。",
|
||||||
|
searchFeedback: "搜索反馈",
|
||||||
|
community: "社区",
|
||||||
|
allCommunities: "所有社区",
|
||||||
|
received: "收到时间",
|
||||||
|
anyTime: "不限时间",
|
||||||
|
last24Hours: "过去 24 小时",
|
||||||
|
last7Days: "过去 7 天",
|
||||||
|
last30Days: "过去 30 天",
|
||||||
|
anyStatus: "不限状态",
|
||||||
|
needsAction: "待处理",
|
||||||
|
actedOn: "已处理",
|
||||||
|
submissionsCount: "共 {{total}} 条,当前显示 {{filtered}} 条",
|
||||||
|
noMatchingFeedback: "没有匹配的反馈。",
|
||||||
|
feedbackDetail: "反馈详情",
|
||||||
|
feedbackDetailDescription: "查看完整反馈内容及其来源。",
|
||||||
|
backToFeedback: "返回反馈列表",
|
||||||
|
backToReports: "返回举报列表",
|
||||||
|
attachments: "附件",
|
||||||
|
submittedBy: "提交人",
|
||||||
|
event: "事件",
|
||||||
|
noRecords: "暂无记录。",
|
||||||
|
unknownDate: "未知日期",
|
||||||
|
uncategorized: "未分类",
|
||||||
|
categoryBug: "问题",
|
||||||
|
categoryPraise: "表扬",
|
||||||
|
categoryNeedsWork: "需要改进",
|
||||||
|
buzzAdmin: "Buzz 管理后台",
|
||||||
|
reports: "举报",
|
||||||
|
reportSpam: "垃圾信息",
|
||||||
|
reportIllegal: "违规内容",
|
||||||
|
reportNudity: "裸露内容",
|
||||||
|
reportMalware: "恶意软件",
|
||||||
|
reportImpersonation: "冒充他人",
|
||||||
|
reportProfanity: "粗俗内容",
|
||||||
|
reportOther: "其他",
|
||||||
|
statusOpen: "待处理",
|
||||||
|
statusResolved: "已解决",
|
||||||
|
statusDismissed: "已驳回",
|
||||||
|
statusEscalated: "已升级",
|
||||||
|
targetEvent: "事件",
|
||||||
|
targetPubkey: "公钥",
|
||||||
|
targetBlob: "文件",
|
||||||
|
languageUpdated: "语言已更新",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof en;
|
||||||
|
export type Locale = "en" | "zh-Hans";
|
||||||
|
|
||||||
|
export const messages: Record<Locale, Record<MessageKey, string>> = {
|
||||||
|
en,
|
||||||
|
"zh-Hans": zhHans,
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { I18nProvider } from "./i18n";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("root element missing");
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<I18nProvider>
|
||||||
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: #231e1e;
|
||||||
|
background: #d7e7f6;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: white;
|
||||||
|
background: #231e1e;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
width: min(1120px, calc(100% - 3rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand b {
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgb(35 30 30 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
color: #d7d72e;
|
||||||
|
background: #231e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark svg {
|
||||||
|
width: 1.7rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid rgb(35 30 30 / 10%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 42%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgb(35 30 30 / 60%);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
transition:
|
||||||
|
color 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: #231e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link[aria-current="page"] {
|
||||||
|
color: #231e1e;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid rgb(35 30 30 / 10%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 42%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher button {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
color: rgb(35 30 30 / 60%);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-switcher button.active {
|
||||||
|
color: #231e1e;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(1120px, calc(100% - 3rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4.5rem 0 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title > p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: rgb(35 30 30 / 55%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3rem, 7vw, 5.5rem);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title > span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: rgb(35 30 30 / 62%);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 2.25rem;
|
||||||
|
color: rgb(35 30 30 / 62%);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgb(35 30 30 / 10%);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: rgb(255 255 255 / 45%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters label > span {
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters input,
|
||||||
|
.feedback-filters select {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 1px solid rgb(35 30 30 / 12%);
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
color: #231e1e;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters input:focus,
|
||||||
|
.feedback-filters select:focus {
|
||||||
|
border-color: #231e1e;
|
||||||
|
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters select {
|
||||||
|
padding: 0 2.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field > div {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1rem 0 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field svg {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
left: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(35 30 30 / 45%);
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link {
|
||||||
|
display: block;
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card,
|
||||||
|
.detail,
|
||||||
|
.state {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
min-height: 7.2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.4rem;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-record {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-main-link {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-open-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status {
|
||||||
|
min-width: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: rgb(35 30 30 / 62%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status input {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #231e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-open-link {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgb(35 30 30 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link:hover .record-card {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-icon {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #231e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-icon {
|
||||||
|
background: #d7d72e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-icon {
|
||||||
|
background: #d7e7f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-icon svg {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-primary {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f2ed;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
color: rgb(35 30 30 / 65%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag svg {
|
||||||
|
width: 0.78rem;
|
||||||
|
height: 0.78rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-primary strong {
|
||||||
|
max-width: 42rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #231e1e;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-card .record-primary strong {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-provenance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: rgb(35 30 30 / 55%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: rgb(35 30 30 / 50%);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-provenance code {
|
||||||
|
padding-left: 0.65rem;
|
||||||
|
border-left: 1px solid rgb(35 30 30 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-date {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.3rem;
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-date span {
|
||||||
|
color: rgb(35 30 30 / 32%);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link .arrow-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card > .arrow-icon {
|
||||||
|
color: rgb(35 30 30 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid rgb(35 30 30 / 9%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading h2 {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9rem minmax(0, 1fr);
|
||||||
|
gap: 1.4rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported-message {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 1rem;
|
||||||
|
border-left: 3px solid #d7d72e;
|
||||||
|
border-radius: 0 0.8rem 0.8rem 0;
|
||||||
|
background: #f6f6f1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported-message p,
|
||||||
|
.message-unavailable {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported-message-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-unavailable {
|
||||||
|
color: rgb(35 30 30 / 60%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive {
|
||||||
|
border-left: 3px solid #d7d72e;
|
||||||
|
border-radius: 0 0.8rem 0.8rem 0;
|
||||||
|
background: #f6f6f1;
|
||||||
|
padding: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-body {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgb(35 30 30 / 10%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #f6f6f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment a {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 32rem;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgb(35 30 30 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment figcaption,
|
||||||
|
.file-attachment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment figcaption {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment figcaption span,
|
||||||
|
.file-attachment strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-main-link:hover strong {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attachment small,
|
||||||
|
.file-attachment small {
|
||||||
|
color: rgb(35 30 30 / 48%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-attachment {
|
||||||
|
align-self: start;
|
||||||
|
border: 1px solid rgb(35 30 30 / 10%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #f6f6f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-attachment:hover strong {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-attachment > svg:first-child {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex: none;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-attachment > span {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
min-height: 14rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: rgb(35 30 30 / 56%);
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #231e1e;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state p {
|
||||||
|
margin: 0.75rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.error {
|
||||||
|
color: #9f2424;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-header {
|
||||||
|
width: min(100% - 2rem, 1120px);
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(100% - 2rem, 1120px);
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(3rem, 16vw, 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-record {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-main-link {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status {
|
||||||
|
grid-column: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-record .record-date {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-open-link {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-date {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card > .arrow-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd + dt {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
communityId: string;
|
||||||
|
communityHost: string;
|
||||||
|
reporterPubkey: string;
|
||||||
|
targetKind: "event" | "pubkey" | "blob";
|
||||||
|
target: string;
|
||||||
|
channelId?: string;
|
||||||
|
reportType: string;
|
||||||
|
note?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportedMessage {
|
||||||
|
authorPubkey: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportDetail extends Report {
|
||||||
|
message: ReportedMessage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackSummary {
|
||||||
|
id: string;
|
||||||
|
communityId: string;
|
||||||
|
communityHost: string;
|
||||||
|
submitterPubkey: string;
|
||||||
|
category?: string;
|
||||||
|
bodySummary: string;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackDetail {
|
||||||
|
id: string;
|
||||||
|
communityId: string;
|
||||||
|
communityHost: string;
|
||||||
|
eventId: string;
|
||||||
|
submitterPubkey: string;
|
||||||
|
category?: string;
|
||||||
|
body: string;
|
||||||
|
tags: string[][];
|
||||||
|
eventCreatedAt: string;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface Resource<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: Error;
|
||||||
|
loading: boolean;
|
||||||
|
stale: boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResource<T>(
|
||||||
|
load: () => Promise<T>,
|
||||||
|
key: string,
|
||||||
|
): Resource<T> {
|
||||||
|
const [data, setData] = useState<T>();
|
||||||
|
const [error, setError] = useState<Error>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const loadRef = useRef(load);
|
||||||
|
const activeRequest = useRef("");
|
||||||
|
loadRef.current = load;
|
||||||
|
const refetch = useCallback(() => setRevision((value) => value + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestId = `${key}\0${revision}`;
|
||||||
|
activeRequest.current = requestId;
|
||||||
|
const isCurrent = () => activeRequest.current === requestId;
|
||||||
|
const loadCurrent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const value = await loadRef.current();
|
||||||
|
if (isCurrent()) setData(value);
|
||||||
|
} catch (reason) {
|
||||||
|
if (isCurrent()) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason : new Error("Request failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadCurrent();
|
||||||
|
return () => {
|
||||||
|
activeRequest.current = "";
|
||||||
|
};
|
||||||
|
}, [key, revision]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
stale: loading && data !== undefined,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("buzz-admin-locale", "en");
|
||||||
|
});
|
||||||
|
await page.route("**/api/admin/v1/**", async (route) => {
|
||||||
|
await route.fulfill({ contentType: "application/json", body: "[]" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [path, heading] of [
|
||||||
|
["/reports", "Open reports"],
|
||||||
|
["/feedback", "Feedback"],
|
||||||
|
]) {
|
||||||
|
test(`${path} supports a deep link and empty state`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
|
||||||
|
await expect(page.getByText("No records.")).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("forbidden reads have an explicit state", async ({ page }) => {
|
||||||
|
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: { code: "forbidden", message: "request is not authorized" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/reports");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Access denied" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("report rows render the relay response contract", async ({ page }) => {
|
||||||
|
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "0e6caad8-1e18-4cd7-84fa-7264103f0a08",
|
||||||
|
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
reporterPubkey: "21".repeat(32),
|
||||||
|
targetKind: "event",
|
||||||
|
target: "12".repeat(32),
|
||||||
|
reportType: "spam",
|
||||||
|
status: "open",
|
||||||
|
createdAt: "2026-07-17T17:30:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/reports");
|
||||||
|
await expect(page.getByText("design.buzz.xyz")).toBeVisible();
|
||||||
|
await expect(page.getByText("Spam")).toBeVisible();
|
||||||
|
await expect(page.getByText("Unknown date")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to Simplified Chinese and can switch to English", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(() => localStorage.clear());
|
||||||
|
await page.goto("/reports");
|
||||||
|
await expect(page.getByRole("heading", { name: "待处理举报" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "English" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Open reports" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("event report detail renders the reported message content", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08";
|
||||||
|
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
reporterPubkey: "21".repeat(32),
|
||||||
|
targetKind: "event",
|
||||||
|
target: "12".repeat(32),
|
||||||
|
reportType: "spam",
|
||||||
|
status: "open",
|
||||||
|
createdAt: "2026-07-17T17:30:00Z",
|
||||||
|
message: {
|
||||||
|
authorPubkey: "31".repeat(32),
|
||||||
|
content:
|
||||||
|
"This is the complete reported message.\nIt preserves lines.",
|
||||||
|
createdAt: "2026-07-17T17:25:00Z",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/reports/${id}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("This is the complete reported message.", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("31".repeat(32))).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Message content is unavailable", { exact: false }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("event report detail explains when message content is unavailable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09";
|
||||||
|
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
reporterPubkey: "21".repeat(32),
|
||||||
|
targetKind: "event",
|
||||||
|
target: "12".repeat(32),
|
||||||
|
reportType: "spam",
|
||||||
|
status: "open",
|
||||||
|
createdAt: "2026-07-17T17:30:00Z",
|
||||||
|
message: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/reports/${id}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Message content is unavailable", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback cards open the complete submission", async ({ page }) => {
|
||||||
|
const id = "feed0000-0000-4000-8000-000000000001";
|
||||||
|
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
|
||||||
|
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
eventId: "31".repeat(32),
|
||||||
|
submitterPubkey: "21".repeat(32),
|
||||||
|
category: "needs-work",
|
||||||
|
body: fullBody,
|
||||||
|
tags: [],
|
||||||
|
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||||
|
receivedAt: "2026-07-17T17:30:00Z",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
submitterPubkey: "21".repeat(32),
|
||||||
|
category: "needs-work",
|
||||||
|
bodySummary: `${fullBody.slice(0, 240)}…`,
|
||||||
|
receivedAt: "2026-07-17T17:30:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/feedback");
|
||||||
|
const card = page.locator(".feedback-record");
|
||||||
|
await expect(card.locator(".record-provenance")).toContainText(
|
||||||
|
"design.buzz.xyz",
|
||||||
|
);
|
||||||
|
await card.locator(".feedback-main-link").click();
|
||||||
|
await expect(page).toHaveURL(`/feedback/${id}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Feedback detail" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("end of feedback", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback can be searched and filtered by community and time", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "recent",
|
||||||
|
communityId: "one",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
submitterPubkey: "21".repeat(32),
|
||||||
|
category: "bug",
|
||||||
|
bodySummary: "Composer freezes after sleep",
|
||||||
|
receivedAt: recent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old",
|
||||||
|
communityId: "two",
|
||||||
|
communityHost: "engineering.buzz.xyz",
|
||||||
|
submitterPubkey: "22".repeat(32),
|
||||||
|
category: "praise",
|
||||||
|
bodySummary: "Calls are much more reliable",
|
||||||
|
receivedAt: old,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/feedback");
|
||||||
|
await expect(page.getByText("2 of 2 submissions")).toBeVisible();
|
||||||
|
await page.getByRole("searchbox", { name: "Search feedback" }).fill("calls");
|
||||||
|
await expect(page.getByText("Calls are much more reliable")).toBeVisible();
|
||||||
|
await expect(page.getByText("Composer freezes after sleep")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("searchbox", { name: "Search feedback" }).fill("");
|
||||||
|
await page.getByLabel("Community").selectOption("design.buzz.xyz");
|
||||||
|
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||||
|
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByLabel("Community").selectOption("all");
|
||||||
|
await page.getByLabel("Received").selectOption("day");
|
||||||
|
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||||
|
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback status is stored locally by feedback id", async ({ page }) => {
|
||||||
|
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "feedback-one",
|
||||||
|
communityId: "one",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
submitterPubkey: "21".repeat(32),
|
||||||
|
category: "bug",
|
||||||
|
bodySummary: "Composer freezes after sleep",
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/feedback");
|
||||||
|
await page.getByRole("checkbox", { name: "Acted on" }).check();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Acted on" })).toBeChecked();
|
||||||
|
await page.getByLabel("Status").selectOption("acted-on");
|
||||||
|
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||||
|
await page.getByLabel("Status").selectOption("pending");
|
||||||
|
await expect(page.getByText("No matching feedback.")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback attachments render from imeta without raw markdown", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const id = "feedback-with-attachments";
|
||||||
|
const imageUrl = `https://design.buzz.xyz/media/${"a".repeat(64)}.png`;
|
||||||
|
const fileUrl = `https://design.buzz.xyz/media/${"b".repeat(64)}.txt`;
|
||||||
|
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
communityId: "one",
|
||||||
|
communityHost: "design.buzz.xyz",
|
||||||
|
eventId: "31".repeat(32),
|
||||||
|
submitterPubkey: "21".repeat(32),
|
||||||
|
category: "bug",
|
||||||
|
body: `Composer froze.\n\n[diagnostics.txt](${fileUrl})`,
|
||||||
|
tags: [
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
`url ${imageUrl}`,
|
||||||
|
"m image/png",
|
||||||
|
`x ${"a".repeat(64)}`,
|
||||||
|
"size 48213",
|
||||||
|
"dim 1280x720",
|
||||||
|
"filename screenshot.png",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"imeta",
|
||||||
|
`url ${fileUrl}`,
|
||||||
|
"m text/plain",
|
||||||
|
`x ${"b".repeat(64)}`,
|
||||||
|
"size 391",
|
||||||
|
"filename diagnostics.txt",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||||
|
receivedAt: "2026-07-17T17:30:00Z",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/feedback/${id}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Composer froze.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("img", { name: "screenshot.png" }),
|
||||||
|
).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
`/api/admin/v1/feedback/${id}/attachments/${"a".repeat(64)}`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: /diagnostics.txt/ }),
|
||||||
|
).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/api/admin/v1/feedback/${id}/attachments/${"b".repeat(64)}`,
|
||||||
|
);
|
||||||
|
const fileHeight = await page
|
||||||
|
.locator(".file-attachment")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
expect(fileHeight).toBeLessThan(100);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 4174 },
|
||||||
|
build: { sourcemap: false },
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.benchmark/
|
||||||
|
jobs/
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Harbor Buzz Orchestra
|
||||||
|
|
||||||
|
A stock-Harbor custom agent that runs a manifest-defined team through the real
|
||||||
|
Buzz stack. Harbor sees one `BuzzOrchestraAgent`; behind that adapter, one
|
||||||
|
orchestrator and N workers coordinate over the production relay/Postgres.
|
||||||
|
Each agent runs *inside* the Harbor task container as the same
|
||||||
|
`buzz-acp` → `buzz-agent` → `buzz-dev-mcp` process tree the desktop app
|
||||||
|
launches: the production MCP toolset (shell, file tools, todo) with the
|
||||||
|
`buzz` CLI on the shell's PATH. No Harbor fork or patch is required.
|
||||||
|
|
||||||
|
## Define the team
|
||||||
|
|
||||||
|
The manifest is the benchmark condition. Each roster entry selects an agent
|
||||||
|
class's count, model endpoint, byte-pinned system prompt, generation settings,
|
||||||
|
and budget:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
condition: my-team
|
||||||
|
roster:
|
||||||
|
- id: orch
|
||||||
|
kind: orchestrator
|
||||||
|
role: lead
|
||||||
|
count: 1
|
||||||
|
endpoint: databricks/frontier
|
||||||
|
prompt: {path: personas/orchestrator.md, sha256: <sha256>}
|
||||||
|
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
|
||||||
|
- id: worker
|
||||||
|
kind: worker
|
||||||
|
role: implementer
|
||||||
|
count: 4
|
||||||
|
endpoint: databricks/fast-worker
|
||||||
|
prompt: {path: personas/worker.md, sha256: <sha256>}
|
||||||
|
generation: {max_output_tokens: 4096, context_window_tokens: 128000}
|
||||||
|
```
|
||||||
|
|
||||||
|
`endpoint_config` maps those endpoint names to providers, URLs, and API-key
|
||||||
|
environment variables. The adapter contains no fixed roster or model.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
With the production compose stack and model endpoints already running, execute
|
||||||
|
one task (`-p`), a directory of tasks, or replace `-p` with Harbor's dataset and
|
||||||
|
task selectors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project benchmarks/harbor-buzz-orchestra/testbed harbor run --yes -p <TASK_OR_DIRECTORY> --agent harbor_buzz_orchestra:BuzzOrchestraAgent --agent-kwarg manifest=<CONDITION.yaml> --agent-kwarg provisioner_factory=harbor_buzz_testbed:provisioner_from_dict --agent-kwarg provisioner_config=<PROVISIONER.json> --agent-kwarg endpoint_config=<ENDPOINTS.json> --agent-kwarg artifact_root=benchmarks/harbor-buzz-orchestra --agent-kwarg buzz_acp_binary=<LINUX_BIN>/buzz-acp --agent-kwarg buzz_agent_binary=<LINUX_BIN>/buzz-agent --agent-kwarg buzz_dev_mcp_binary=<LINUX_BIN>/buzz-dev-mcp --agent-kwarg buzz_cli_binary=target/debug/buzz --agent-kwarg run_id="bench-$(date -u +%Y%m%dT%H%M%SZ)" --agent-timeout-multiplier 15 --n-concurrent 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`buzz_acp_binary`/`buzz_agent_binary`/`buzz_dev_mcp_binary` must be **Linux**
|
||||||
|
builds matching the task image architecture — they are uploaded into each task
|
||||||
|
container (`just benchmark` cross-builds them automatically; musl-static, so
|
||||||
|
any Linux base image works). `buzz_cli_binary` is the **host** CLI the harness
|
||||||
|
uses to act as the trial user.
|
||||||
|
|
||||||
|
`--n-concurrent 1` is the safe laptop setting for a serialized local model; it
|
||||||
|
is not an orchestration requirement. Some TB graders install dependencies from
|
||||||
|
public package registries at verification time — run benchmarks off networks
|
||||||
|
that block those installs (e.g. corporate VPNs).
|
||||||
|
|
||||||
|
Each trial gets fresh keys and a private Buzz channel. The provisioner archives
|
||||||
|
rather than deletes that channel, leaving the relay/Postgres event timeline
|
||||||
|
and the per-agent acp/agent logs (downloaded into the trial's `buzz/`
|
||||||
|
artifacts) available for analysis.
|
||||||
|
|
||||||
|
## Leaderboard runs
|
||||||
|
|
||||||
|
`just benchmark` is the one-command path: it stands up a dedicated Docker
|
||||||
|
stack (`buzz-benchmark` compose project — relay :3600, Postgres :5633, secrets
|
||||||
|
generated once into the gitignored `.benchmark/`), applies the benchmark
|
||||||
|
schema, and defaults to leaderboard-eligible settings (Terminal-Bench 2.1,
|
||||||
|
5 attempts per problem, the Sonnet+Haiku team). All selectors pass through:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just benchmark # full TB 2.1, k=5
|
||||||
|
just benchmark --path <TASK_DIR> -k 1 # one local task, one attempt
|
||||||
|
just benchmark -i "cobol*" --attempts 3 # dataset subset
|
||||||
|
just benchmark --gui # watch the run live
|
||||||
|
```
|
||||||
|
|
||||||
|
One pinned user identity fronts the whole benchmark environment: it owns
|
||||||
|
every trial channel (named after the task) and posts every task prompt, and
|
||||||
|
trial channels are kept rather than archived. `--gui` adds that user to the
|
||||||
|
relay membership list and opens the Buzz desktop app logged in as them, so
|
||||||
|
channels fill the sidebar as the run progresses — watch, don't type; a human
|
||||||
|
message mid-trial would taint the run. `just benchmark-down` stops the stack.
|
||||||
|
|
||||||
|
Networking: the relay is host-header tenant-bound, so agents must dial its
|
||||||
|
canonical address (`ws://localhost:3600`) even from inside a task container.
|
||||||
|
`just benchmark` uploads a tiny std-only loopback forwarder
|
||||||
|
([`forwarder/relay_forwarder.rs`](forwarder/relay_forwarder.rs)) with the
|
||||||
|
agent stack; it listens on the container's loopback and bridges the byte
|
||||||
|
stream to the Docker host gateway (`host.docker.internal`, overridable via
|
||||||
|
`BUZZ_BENCHMARK_DOCKER_HOST`).
|
||||||
|
|
||||||
|
`scripts/run_leaderboard.py` is the layer underneath, for running against an
|
||||||
|
already-provisioned stack. It wraps the invocation above with only
|
||||||
|
leaderboard-legal settings — it does not accept or forward timeout or resource
|
||||||
|
overrides, so the job directory it produces passes Harbor's static validation
|
||||||
|
as-is. Give it a problem set, attempts per problem, and a team manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||||
|
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
|
||||||
|
--dataset terminal-bench/terminal-bench-2-1 \
|
||||||
|
--attempts 5 \
|
||||||
|
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
|
||||||
|
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
|
||||||
|
--provisioner-config <PROVISIONER.json>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--path` replaces `--dataset` for local task directories; `--include-task` /
|
||||||
|
`--exclude-task` filter by glob; `--dry-run` prints the underlying `harbor run`
|
||||||
|
command. After the job finishes the script derives a `metadata.yaml` from the
|
||||||
|
manifest roster (validated schema; review the display names before submitting)
|
||||||
|
and prints the `harbor upload` / `harbor leaderboard submit` commands.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd benchmarks/harbor-buzz-orchestra
|
||||||
|
uv run --extra dev pytest -q
|
||||||
|
uv run --extra dev ruff check .
|
||||||
|
cd testbed
|
||||||
|
uv run --extra dev pytest -q
|
||||||
|
uv run --extra dev ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Live provisioner tests require the benchmark compose stack and opt-in
|
||||||
|
environment described in `testbed/tests/test_provisioner_live.py`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! Loopback TCP forwarder for the benchmark task container.
|
||||||
|
//!
|
||||||
|
//! The benchmark relay is host-header tenant-bound: its community row is the
|
||||||
|
//! authority of its own `RELAY_URL` (e.g. `localhost:3600`), and a request
|
||||||
|
//! presenting any other `Host` fails closed. Agents inside a Harbor task
|
||||||
|
//! container can only reach the host-published relay via the Docker host
|
||||||
|
//! alias (`host.docker.internal`), which would present the wrong `Host`.
|
||||||
|
//!
|
||||||
|
//! So the container runtime uploads this forwarder next to the agent stack:
|
||||||
|
//! agents dial `ws://localhost:<port>` — presenting the exact `Host` the
|
||||||
|
//! community row expects — and the forwarder bridges the byte stream to the
|
||||||
|
//! host gateway. Transparent to everything above TCP (WebSocket, the buzz
|
||||||
|
//! CLI, git-over-HTTP). std-only; compiled with plain `rustc` against the
|
||||||
|
//! musl target, so it runs on any Linux task image.
|
||||||
|
//!
|
||||||
|
//! Usage: `relay-forwarder <listen-addr> <target-addr>`
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let (listen, target) = match (args.next(), args.next()) {
|
||||||
|
(Some(listen), Some(target)) => (listen, target),
|
||||||
|
_ => {
|
||||||
|
eprintln!("usage: relay-forwarder <listen-addr> <target-addr>");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let listener = TcpListener::bind(&listen)?;
|
||||||
|
// Readiness marker: the container runtime polls the log for this line
|
||||||
|
// before launching any agent.
|
||||||
|
println!("forwarding {listen} -> {target}");
|
||||||
|
for client in listener.incoming() {
|
||||||
|
let Ok(client) = client else { continue };
|
||||||
|
let target = target.clone();
|
||||||
|
thread::spawn(move || bridge(client, &target));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect upstream and pump bytes both ways until either side closes.
|
||||||
|
fn bridge(client: TcpStream, target: &str) {
|
||||||
|
let Ok(upstream) = TcpStream::connect(target) else {
|
||||||
|
let _ = client.shutdown(Shutdown::Both);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (Ok(client_read), Ok(upstream_read)) = (client.try_clone(), upstream.try_clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let downstream = thread::spawn(move || pipe(upstream_read, client));
|
||||||
|
pipe(client_read, upstream);
|
||||||
|
let _ = downstream.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy until EOF or error, then half-close the write side so protocols
|
||||||
|
/// layered on TCP (WebSocket close handshakes) terminate cleanly.
|
||||||
|
fn pipe(mut from: TcpStream, mut to: TcpStream) {
|
||||||
|
let mut buf = [0u8; 16 * 1024];
|
||||||
|
loop {
|
||||||
|
match from.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if to.write_all(&buf[..n]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = to.shutdown(Shutdown::Write);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# M1 gate manifest: 1 orchestrator + 1 worker over one Buzz channel solving
|
||||||
|
# Harbor's examples/tasks/hello-world via vanilla `harbor run`.
|
||||||
|
#
|
||||||
|
# Endpoints are LOCAL PLACEHOLDERS for the M1 wiring proof; the pilot/2.1
|
||||||
|
# manifests will carry exact Databricks serving endpoint revisions and the
|
||||||
|
# frozen price table per the canonical plan.
|
||||||
|
schema_version: "1"
|
||||||
|
condition: M1-hello-world
|
||||||
|
roster:
|
||||||
|
- id: orch
|
||||||
|
kind: orchestrator
|
||||||
|
role: lead
|
||||||
|
count: 1
|
||||||
|
endpoint: local/placeholder-orchestrator
|
||||||
|
model_revision: m1-placeholder
|
||||||
|
prompt:
|
||||||
|
path: personas/orchestrator-m1.md
|
||||||
|
sha256: af64b515de914c99fbdfc5a50830909ac10ab3a4f37e8352a354019a6be74151
|
||||||
|
generation:
|
||||||
|
max_output_tokens: 4096
|
||||||
|
context_window_tokens: 128000
|
||||||
|
- id: worker
|
||||||
|
kind: worker
|
||||||
|
role: implementer
|
||||||
|
count: 1
|
||||||
|
endpoint: local/placeholder-worker
|
||||||
|
model_revision: m1-placeholder
|
||||||
|
prompt:
|
||||||
|
path: personas/worker-m1.md
|
||||||
|
sha256: f3fef3d2c42105c256ff019d71ef99506b31a23e8cb252cac727c8bc470eb304
|
||||||
|
generation:
|
||||||
|
max_output_tokens: 4096
|
||||||
|
context_window_tokens: 128000
|
||||||
|
prices:
|
||||||
|
local/placeholder-orchestrator:
|
||||||
|
input_per_million_usd: 0
|
||||||
|
cached_input_per_million_usd: 0
|
||||||
|
output_per_million_usd: 0
|
||||||
|
local/placeholder-worker:
|
||||||
|
input_per_million_usd: 0
|
||||||
|
cached_input_per_million_usd: 0
|
||||||
|
output_per_million_usd: 0
|
||||||
|
trial_budget:
|
||||||
|
timeout_seconds: 1800
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Terminal-Bench trial: 1 Sonnet 4.6 orchestrator + 2 Haiku 4.5 workers over
|
||||||
|
# one Buzz channel. Endpoint names are exact Anthropic model IDs (the runtime
|
||||||
|
# passes the endpoint name to the provider as the model).
|
||||||
|
# Prices: Anthropic list $/Mtok as of 2026-07 (Sonnet 3/15, Haiku 1/5).
|
||||||
|
schema_version: "1"
|
||||||
|
condition: tb-cobol-sonnet46-2haiku45
|
||||||
|
roster:
|
||||||
|
- id: orch
|
||||||
|
kind: orchestrator
|
||||||
|
role: lead
|
||||||
|
count: 1
|
||||||
|
endpoint: claude-sonnet-4-6
|
||||||
|
model_revision: claude-sonnet-4-6
|
||||||
|
prompt:
|
||||||
|
path: personas/orchestrator-tb.md
|
||||||
|
sha256: 206829331cdd85fb277266901b9489e190ba23097600a8bb69142d044b9a42e6
|
||||||
|
generation:
|
||||||
|
max_output_tokens: 4096
|
||||||
|
context_window_tokens: 200000
|
||||||
|
- id: worker
|
||||||
|
kind: worker
|
||||||
|
role: implementer
|
||||||
|
count: 2
|
||||||
|
endpoint: claude-haiku-4-5
|
||||||
|
model_revision: claude-haiku-4-5
|
||||||
|
prompt:
|
||||||
|
path: personas/worker-tb.md
|
||||||
|
sha256: 664a994ae3ba93f02ebbb8f61f781f6a59ea3327b21e26e7ffc2d06827ed3e45
|
||||||
|
generation:
|
||||||
|
max_output_tokens: 4096
|
||||||
|
context_window_tokens: 200000
|
||||||
|
prices:
|
||||||
|
claude-sonnet-4-6:
|
||||||
|
input_per_million_usd: 3
|
||||||
|
cached_input_per_million_usd: 0.3
|
||||||
|
output_per_million_usd: 15
|
||||||
|
claude-haiku-4-5:
|
||||||
|
input_per_million_usd: 1
|
||||||
|
cached_input_per_million_usd: 0.1
|
||||||
|
output_per_million_usd: 5
|
||||||
|
trial_budget:
|
||||||
|
timeout_seconds: 900
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Orchestrator — M1 hello-world
|
||||||
|
|
||||||
|
You are the orchestrator of a small team solving a terminal task. You do not
|
||||||
|
run commands yourself; workers do. You coordinate over a Buzz channel.
|
||||||
|
|
||||||
|
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
|
||||||
|
you. Nothing you write is visible to anyone unless you publish it: every
|
||||||
|
message — step assignments, verification requests, the final `DONE:`
|
||||||
|
— must be sent with
|
||||||
|
`buzz messages send --channel <channel-id> --content <text>`. Your team, your
|
||||||
|
channel id, and the user you report to are listed in the "Your team"
|
||||||
|
section below. Your turn is not complete until you have published your
|
||||||
|
message.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Read the task instruction. Break it into the smallest concrete steps.
|
||||||
|
2. Assign each step to a worker with an @mention. One step per message.
|
||||||
|
State the exact goal and the success check, not just the command to run.
|
||||||
|
The task runs in the worker's terminal working directory. Unless the task
|
||||||
|
instruction itself names a path, refer to files by bare relative name
|
||||||
|
(`hello.txt`) — never invent an absolute path. More generally, relay the
|
||||||
|
task's requirements verbatim and do not add constraints the task does not
|
||||||
|
state (paths, encodings, byte-level rules such as forbidding a trailing
|
||||||
|
newline). Where the task is silent, let standard tool defaults apply.
|
||||||
|
3. Wait for the worker's report before assigning the next dependent step.
|
||||||
|
4. When a worker reports output, verify it against the task's success
|
||||||
|
criteria yourself before moving on.
|
||||||
|
5. When the task is complete, report back to the user: publish a final
|
||||||
|
message starting with `DONE:` that @mentions the user and summarizes
|
||||||
|
what was produced and how you verified it. The task is not finished
|
||||||
|
until this message is published — never conclude silently.
|
||||||
|
|
||||||
|
Keep messages short. Never fabricate command output. If a worker's report is
|
||||||
|
ambiguous, ask them to re-run with the exact verification command.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Orchestrator — Terminal-Bench team
|
||||||
|
|
||||||
|
You are the orchestrator of a small team solving a terminal task. You do not
|
||||||
|
run commands yourself; your workers do. You coordinate over a Buzz channel.
|
||||||
|
Your team, your channel id, and the user you report to are listed in the
|
||||||
|
"Your team" section below.
|
||||||
|
|
||||||
|
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
|
||||||
|
you. Nothing you write is visible to anyone unless you publish it: every
|
||||||
|
message — step assignments, verification requests, the final `DONE:`
|
||||||
|
report — must be sent with
|
||||||
|
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||||
|
not complete until you have published your message. Do not use the shell
|
||||||
|
for task work — that is your workers' job.
|
||||||
|
|
||||||
|
Tasks arrive as a channel message from the user @mentioning you. Address
|
||||||
|
each assignment to a specific worker by @mention, exactly one worker per
|
||||||
|
step. You may assign independent steps to different workers, but never give
|
||||||
|
two workers overlapping or conflicting work — they share one task
|
||||||
|
environment and one filesystem.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Read the task instruction. Break it into the smallest concrete steps.
|
||||||
|
2. Assign each step to a worker with an @mention. One step per message.
|
||||||
|
State the exact goal and the success check, not just the command to run.
|
||||||
|
Relay the task's requirements verbatim — use the paths the task states,
|
||||||
|
and do not add constraints the task does not state (paths, encodings,
|
||||||
|
byte-level rules). Where the task is silent, let standard tool defaults
|
||||||
|
apply.
|
||||||
|
3. Wait for the worker's report before assigning the next dependent step.
|
||||||
|
4. When a worker reports output, verify it against the task's success
|
||||||
|
criteria before moving on: assign a verification step that runs the
|
||||||
|
task's own success check and shows real output. Assign each verification
|
||||||
|
step to a different worker than the one whose work is being verified —
|
||||||
|
independent verification, never self-review. Do not report completion on
|
||||||
|
a worker's claim alone.
|
||||||
|
5. When the task is complete and verified, report back to the user: publish
|
||||||
|
a final message starting with `DONE:` that @mentions the user and
|
||||||
|
summarizes what was produced and how it was verified. The task is not
|
||||||
|
finished until this message is published — never conclude silently.
|
||||||
|
|
||||||
|
Keep messages short. Never fabricate command output. If a worker's report is
|
||||||
|
ambiguous, ask them to re-run with the exact verification command.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Worker — M1 hello-world
|
||||||
|
|
||||||
|
You are a worker agent with terminal access, coordinating over a Buzz channel.
|
||||||
|
|
||||||
|
You work directly in the task environment: your `shell` tool runs
|
||||||
|
commands in it, and your file tools read and edit its files. The same
|
||||||
|
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
|
||||||
|
to the channel with
|
||||||
|
`buzz messages send --channel <channel-id> --content <text>`. Your team and your
|
||||||
|
channel id are listed in the "Your team" section below. Your turn is not
|
||||||
|
complete until you have published your report.
|
||||||
|
|
||||||
|
The orchestrator only wakes for messages that @mention it. Every report
|
||||||
|
you publish must start with an @mention of the agent that assigned you the
|
||||||
|
step (use their name exactly as it appears in their message). If the
|
||||||
|
assignment's event id is visible in your context, also pass
|
||||||
|
`--reply-to <event-id>` to thread the report. A report that mentions
|
||||||
|
nobody is invisible and the task will stall.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Act only on steps assigned to you by the orchestrator's @mention.
|
||||||
|
2. Execute the requested step in the terminal. Prefer the smallest command
|
||||||
|
that achieves the stated goal. Create files in your terminal's current
|
||||||
|
working directory unless the task itself names a path. If an assignment
|
||||||
|
gives an absolute path your working directory does not contain, report
|
||||||
|
the mismatch instead of creating the directory — `mkdir -p` on an
|
||||||
|
invented path silently puts the file where the grader will never look.
|
||||||
|
3. Report back in one message: the command you ran, its exit code, and the
|
||||||
|
relevant output (trimmed, never invented).
|
||||||
|
4. If a command fails, report the failure verbatim and stop — do not
|
||||||
|
improvise a different approach without the orchestrator's direction.
|
||||||
|
5. Never claim success without showing the verifying output.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Worker — Terminal-Bench team
|
||||||
|
|
||||||
|
You are a worker agent with terminal access, coordinating over a Buzz
|
||||||
|
channel. Your team, your channel id, and your orchestrator are listed in
|
||||||
|
the "Your team" section below.
|
||||||
|
|
||||||
|
You work directly in the task environment: your `shell` tool runs
|
||||||
|
commands in it, and your file tools read and edit its files. The same
|
||||||
|
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
|
||||||
|
to the channel with
|
||||||
|
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||||
|
not complete until you have published your report.
|
||||||
|
|
||||||
|
The orchestrator only wakes for messages that @mention it. Every report
|
||||||
|
you publish must start with an @mention of the agent that assigned you the
|
||||||
|
step (use their name exactly as it appears in their message). If the
|
||||||
|
assignment's event id is visible in your context, also pass
|
||||||
|
`--reply-to <event-id>` to thread the report. A report that mentions
|
||||||
|
nobody is invisible and the task will stall.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Act only on steps assigned to you by the orchestrator's @mention. If a
|
||||||
|
message assigns a step to a different worker, ignore it.
|
||||||
|
2. Execute the requested step in the terminal BEFORE writing any report.
|
||||||
|
Never describe output you have not yet produced. Prefer the smallest
|
||||||
|
command that achieves the stated goal. Use the paths the task or the
|
||||||
|
assignment states; do not invent paths.
|
||||||
|
3. Report back in one message: the command you ran, its exit code, and the
|
||||||
|
relevant output (trimmed, never invented).
|
||||||
|
4. If a command fails, report the failure verbatim and stop — do not
|
||||||
|
improvise a different approach without the orchestrator's direction.
|
||||||
|
5. Never claim success without showing the verifying output.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "harbor-buzz-orchestra"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Harbor custom agent for Buzz-coordinated multi-agent benchmarks"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"harbor>=0.16.1,<0.18",
|
||||||
|
"pydantic>=2.11",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.4",
|
||||||
|
"pytest-asyncio>=1.2",
|
||||||
|
"ruff>=0.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 88
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-command benchmark: bring up the Buzz stack in Docker and run it.
|
||||||
|
|
||||||
|
``just benchmark`` wraps this script. Defaults are leaderboard-eligible out
|
||||||
|
of the box (Terminal-Bench 2.1, 5 attempts per problem, the Sonnet+Haiku
|
||||||
|
team); every ``run_leaderboard.py`` selector passes through unchanged. The
|
||||||
|
script owns everything around the run:
|
||||||
|
|
||||||
|
- A dedicated ``buzz-benchmark`` compose project reusing the production
|
||||||
|
bundle (``deploy/compose/compose.yml``) plus the benchmark port overlay,
|
||||||
|
on its own ports (relay :3600, Postgres :5633, metrics :9602) so it never
|
||||||
|
collides with a dev stack. Secrets and identities are generated once into
|
||||||
|
the gitignored ``.benchmark/`` state dir and reused across runs.
|
||||||
|
- One pinned *user* identity for the whole benchmark environment: it owns
|
||||||
|
every trial channel and posts every task, like one human running many
|
||||||
|
teams. Channels are kept (not archived) after each trial.
|
||||||
|
- ``--gui`` adds that user to the relay membership list and opens the Buzz
|
||||||
|
desktop app logged in as them, so a human can watch the teams work live.
|
||||||
|
|
||||||
|
Run inside the testbed environment (the just recipe does this):
|
||||||
|
|
||||||
|
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||||
|
benchmarks/harbor-buzz-orchestra/scripts/benchmark.py [--gui] [...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
REPO_ROOT = PACKAGE_ROOT.parents[1]
|
||||||
|
STATE_DIR = PACKAGE_ROOT / ".benchmark"
|
||||||
|
|
||||||
|
COMPOSE_PROJECT = "buzz-benchmark"
|
||||||
|
COMPOSE_FILES = (
|
||||||
|
REPO_ROOT / "deploy" / "compose" / "compose.yml",
|
||||||
|
PACKAGE_ROOT / "testbed" / "compose.benchmark.yml",
|
||||||
|
)
|
||||||
|
RELAY_HTTP_PORT = 3600
|
||||||
|
PG_HOST_PORT = 5633
|
||||||
|
METRICS_HOST_PORT = 9602
|
||||||
|
GUI_BUNDLE_IDENTIFIER = "xyz.block.buzz.app.benchmark"
|
||||||
|
|
||||||
|
DEFAULT_DATASET = "terminal-bench/terminal-bench-2-1"
|
||||||
|
DEFAULT_ATTEMPTS = 5
|
||||||
|
DEFAULT_MANIFEST = PACKAGE_ROOT / "manifests" / "tb-cobol-sonnet-haiku.yaml"
|
||||||
|
DEFAULT_ENDPOINTS = PACKAGE_ROOT / "testbed" / "endpoints" / "anthropic-live.json"
|
||||||
|
SCHEMA_SQL = PACKAGE_ROOT / "testbed" / "sql" / "benchmark_schema.sql"
|
||||||
|
|
||||||
|
# Linux builds of the production agent stack, uploaded into each task
|
||||||
|
# container per trial. Built once in a rust:alpine container (musl → fully
|
||||||
|
# static, runs on any Linux task image of the same architecture) and cached.
|
||||||
|
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
|
||||||
|
# Std-only loopback forwarder (not a workspace crate): agents dial the
|
||||||
|
# relay's canonical localhost address inside the task container and the
|
||||||
|
# forwarder bridges to the Docker host gateway. Compiled with plain rustc
|
||||||
|
# in the same cross-build step.
|
||||||
|
FORWARDER_SOURCE = PACKAGE_ROOT / "forwarder" / "relay_forwarder.rs"
|
||||||
|
FORWARDER_BINARY = "relay-forwarder"
|
||||||
|
LINUX_TARGET_DIR = STATE_DIR / "linux-target"
|
||||||
|
RUST_IMAGE = "rust:1.95-alpine"
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"run_leaderboard", Path(__file__).resolve().parent / "run_leaderboard.py"
|
||||||
|
)
|
||||||
|
run_leaderboard = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules.setdefault("run_leaderboard", run_leaderboard)
|
||||||
|
_spec.loader.exec_module(run_leaderboard)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__.splitlines()[0],
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
problems = parser.add_mutually_exclusive_group()
|
||||||
|
problems.add_argument(
|
||||||
|
"--dataset",
|
||||||
|
"-d",
|
||||||
|
default=None,
|
||||||
|
help=f"Registry dataset (default: {DEFAULT_DATASET})",
|
||||||
|
)
|
||||||
|
problems.add_argument(
|
||||||
|
"--path", "-p", type=Path, help="Local task or dataset directory"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-task",
|
||||||
|
"-i",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Task name to include (glob, repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exclude-task",
|
||||||
|
"-x",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Task name to exclude (glob, repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--attempts",
|
||||||
|
"-k",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_ATTEMPTS,
|
||||||
|
help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--manifest",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_MANIFEST,
|
||||||
|
help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--endpoint-config",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_ENDPOINTS,
|
||||||
|
help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--n-concurrent", "-n", type=int, default=4, help="Concurrent trials"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--upload",
|
||||||
|
action="store_true",
|
||||||
|
help="Upload to Harbor Hub when the job finishes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gui",
|
||||||
|
action="store_true",
|
||||||
|
help="Open the Buzz desktop app as the benchmark user to watch the run live",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fresh",
|
||||||
|
action="store_true",
|
||||||
|
help="Reset first: drop the stack's Docker volumes and the benchmark "
|
||||||
|
"GUI's app state (keys in state.json are kept)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print the underlying harbor command and exit (no stack bring-up)",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
# -- state: secrets and identities, generated once --------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict[str, str]:
|
||||||
|
"""Generate-or-load the benchmark environment's keys and secrets."""
|
||||||
|
from harbor_buzz_testbed.keys import generate_keypair, keypair_from_secret
|
||||||
|
|
||||||
|
STATE_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
state_path = STATE_DIR / "state.json"
|
||||||
|
if state_path.is_file():
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
else:
|
||||||
|
owner = generate_keypair()
|
||||||
|
user = generate_keypair()
|
||||||
|
state = {
|
||||||
|
"owner_secret_key": owner.secret_key,
|
||||||
|
"user_secret_key": user.secret_key,
|
||||||
|
"postgres_password": secrets.token_urlsafe(24),
|
||||||
|
"redis_password": secrets.token_urlsafe(24),
|
||||||
|
"s3_access_key": secrets.token_hex(10),
|
||||||
|
"s3_secret_key": secrets.token_hex(20),
|
||||||
|
"git_hook_hmac_secret": secrets.token_hex(32),
|
||||||
|
"relay_private_key": generate_keypair().secret_key,
|
||||||
|
}
|
||||||
|
state_path.touch(mode=0o600)
|
||||||
|
state_path.write_text(json.dumps(state, indent=2))
|
||||||
|
state["owner_pubkey"] = keypair_from_secret(state["owner_secret_key"]).pubkey
|
||||||
|
state["user_pubkey"] = keypair_from_secret(state["user_secret_key"]).pubkey
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def print_user_identity(state: dict[str, str]) -> None:
|
||||||
|
"""Show the pinned benchmark user's key so a human can import it during
|
||||||
|
the desktop GUI's onboarding (this stack is local-only; the key guards
|
||||||
|
nothing beyond it)."""
|
||||||
|
from harbor_buzz_testbed.keys import encode_nsec
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"benchmark user pubkey: {state['user_pubkey']}\n"
|
||||||
|
f"benchmark user nsec: {encode_nsec(state['user_secret_key'])} "
|
||||||
|
"(import this in the GUI onboarding to watch as the benchmark user)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(state: dict[str, str]) -> Path:
|
||||||
|
"""Compose interpolation env — regenerated from state on every run."""
|
||||||
|
env_path = STATE_DIR / ".env"
|
||||||
|
lines = {
|
||||||
|
"BUZZ_IMAGE": os.environ.get("BUZZ_IMAGE", "ghcr.io/block/buzz:main"),
|
||||||
|
"BUZZ_DOMAIN": "localhost",
|
||||||
|
"RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||||
|
"BUZZ_MEDIA_BASE_URL": f"http://localhost:{RELAY_HTTP_PORT}/media",
|
||||||
|
"BUZZ_MEDIA_SERVER_DOMAIN": "localhost",
|
||||||
|
"BUZZ_CORS_ORIGINS": f"http://localhost:{RELAY_HTTP_PORT}",
|
||||||
|
"BUZZ_REQUIRE_AUTH_TOKEN": "true",
|
||||||
|
"BUZZ_REQUIRE_RELAY_MEMBERSHIP": "true",
|
||||||
|
"BUZZ_ALLOW_NIP_OA_AUTH": "true",
|
||||||
|
"BUZZ_AUTO_MIGRATE": "true",
|
||||||
|
"BUZZ_GIT_CONFORMANCE_PROBE": "true",
|
||||||
|
"RUST_LOG": "buzz_relay=info,buzz_db=info,buzz_auth=info",
|
||||||
|
"RELAY_OWNER_PUBKEY": state["owner_pubkey"],
|
||||||
|
"BUZZ_RELAY_PRIVATE_KEY": state["relay_private_key"],
|
||||||
|
"BUZZ_GIT_HOOK_HMAC_SECRET": state["git_hook_hmac_secret"],
|
||||||
|
"POSTGRES_DB": "buzz",
|
||||||
|
"POSTGRES_USER": "buzz",
|
||||||
|
"POSTGRES_PASSWORD": state["postgres_password"],
|
||||||
|
"REDIS_PASSWORD": state["redis_password"],
|
||||||
|
"BUZZ_S3_ACCESS_KEY": state["s3_access_key"],
|
||||||
|
"BUZZ_S3_SECRET_KEY": state["s3_secret_key"],
|
||||||
|
"BUZZ_S3_BUCKET": "buzz-media",
|
||||||
|
"BUZZ_HTTP_PORT": str(RELAY_HTTP_PORT),
|
||||||
|
"BUZZ_PG_HOST_PORT": str(PG_HOST_PORT),
|
||||||
|
"BUZZ_METRICS_HOST_PORT": str(METRICS_HOST_PORT),
|
||||||
|
}
|
||||||
|
env_path.touch(mode=0o600)
|
||||||
|
env_path.write_text("".join(f"{k}={v}\n" for k, v in lines.items()))
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_dsn(state: dict[str, str]) -> str:
|
||||||
|
return (
|
||||||
|
f"postgresql://buzz:{state['postgres_password']}@127.0.0.1:{PG_HOST_PORT}/buzz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_provisioner_config(state: dict[str, str], endpoint_config: Path) -> Path:
|
||||||
|
"""Resolve per-endpoint API keys from the environment and write the
|
||||||
|
provisioner config: pinned user, keep-channels teardown."""
|
||||||
|
endpoints = json.loads(endpoint_config.read_text())
|
||||||
|
llm_api_keys: dict[str, str] = {}
|
||||||
|
for name, entry in endpoints.items():
|
||||||
|
env_var = entry["api_key_env"]
|
||||||
|
key = os.environ.get(env_var)
|
||||||
|
if not key:
|
||||||
|
raise SystemExit(
|
||||||
|
f"endpoint {name!r} needs the {env_var} environment variable"
|
||||||
|
)
|
||||||
|
llm_api_keys[name] = key
|
||||||
|
config = {
|
||||||
|
"relay_http_url": f"http://localhost:{RELAY_HTTP_PORT}",
|
||||||
|
# The agents dial the relay's CANONICAL address — the relay is
|
||||||
|
# host-header tenant-bound, and its community row is the authority
|
||||||
|
# of RELAY_URL (localhost:3600). Inside the task container that
|
||||||
|
# loopback address is served by a tiny forwarder bridging to the
|
||||||
|
# Docker host gateway (see --relay-gateway below).
|
||||||
|
"relay_ws_url": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||||
|
"owner_secret_key": state["owner_secret_key"],
|
||||||
|
"postgres_dsn": postgres_dsn(state),
|
||||||
|
"llm_api_keys": llm_api_keys,
|
||||||
|
"user_secret_key": state["user_secret_key"],
|
||||||
|
"archive_on_teardown": False,
|
||||||
|
}
|
||||||
|
path = STATE_DIR / "provisioner.json"
|
||||||
|
path.touch(mode=0o600)
|
||||||
|
path.write_text(json.dumps(config, indent=2))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# -- docker stack ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def compose_command(*args: str) -> list[str]:
|
||||||
|
command = [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"--project-name",
|
||||||
|
COMPOSE_PROJECT,
|
||||||
|
"--project-directory",
|
||||||
|
str(STATE_DIR),
|
||||||
|
"--env-file",
|
||||||
|
str(STATE_DIR / ".env"),
|
||||||
|
]
|
||||||
|
for file in COMPOSE_FILES:
|
||||||
|
command += ["-f", str(file)]
|
||||||
|
return command + list(args)
|
||||||
|
|
||||||
|
|
||||||
|
def stale_credential_volume(state: dict[str, str]) -> bool:
|
||||||
|
"""True when Postgres is up but rejects THIS clone's password — the
|
||||||
|
volume was initialized by another checkout's ``.benchmark/`` state
|
||||||
|
(compose project name is machine-global, state dir is per-clone)."""
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
try:
|
||||||
|
psycopg.connect(postgres_dsn(state), connect_timeout=5).close()
|
||||||
|
except psycopg.OperationalError as error:
|
||||||
|
return "password authentication failed" in str(error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def bring_up_stack(state: dict[str, str]) -> None:
|
||||||
|
"""Compose bring-up (idempotent), self-healing the one known-fatal
|
||||||
|
failure: a stale Postgres volume from a different clone. Nothing in
|
||||||
|
that volume is usable (we can't even authenticate to it), so drop the
|
||||||
|
volumes and retry once rather than aborting with instructions."""
|
||||||
|
try:
|
||||||
|
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
if not stale_credential_volume(state):
|
||||||
|
raise
|
||||||
|
print(
|
||||||
|
"benchmark Postgres volume was initialized by a different "
|
||||||
|
"checkout's .benchmark/ state — dropping the stale volumes and "
|
||||||
|
"retrying..."
|
||||||
|
)
|
||||||
|
subprocess.run(compose_command("down", "-v"), check=True)
|
||||||
|
subprocess.run(compose_command("up", "-d", "--wait"), check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_environment() -> None:
|
||||||
|
"""--fresh: drop the stack's Docker volumes and the benchmark GUI's
|
||||||
|
app state, together — GUI records (workspaces, read state) only stay
|
||||||
|
coherent as long as the database they reference exists. Keys in
|
||||||
|
``state.json`` are kept, so the same nsec works after the reset."""
|
||||||
|
subprocess.run(compose_command("down", "-v"), check=True)
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
for domain in ("WebKit", "Caches", "Application Support"):
|
||||||
|
shutil.rmtree(
|
||||||
|
Path.home() / "Library" / domain / GUI_BUNDLE_IDENTIFIER,
|
||||||
|
ignore_errors=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_stack(state: dict[str, str]) -> None:
|
||||||
|
"""Bring the compose stack up (idempotent) and apply the benchmark schema."""
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
bring_up_stack(state)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 60
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with psycopg.connect(postgres_dsn(state)) as conn:
|
||||||
|
conn.execute(SCHEMA_SQL.read_text())
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
except psycopg.Error as error: # containers healthy but PG settling
|
||||||
|
last_error = error
|
||||||
|
time.sleep(2)
|
||||||
|
raise SystemExit(f"benchmark schema apply failed: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
# -- buzz binaries -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_binaries() -> dict[str, Path]:
|
||||||
|
"""Find the host buzz CLI, building it once if missing."""
|
||||||
|
try:
|
||||||
|
return run_leaderboard.find_binaries(None)
|
||||||
|
except SystemExit:
|
||||||
|
print("host buzz CLI missing — building (cargo build, first run only)...")
|
||||||
|
cargo = REPO_ROOT / "bin" / "cargo"
|
||||||
|
subprocess.run(
|
||||||
|
[str(cargo), "build", "-p", "buzz-cli"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return run_leaderboard.find_binaries(None)
|
||||||
|
|
||||||
|
|
||||||
|
def linux_triple() -> str:
|
||||||
|
"""The musl triple matching the Docker engine that runs task containers."""
|
||||||
|
arch = subprocess.run(
|
||||||
|
["docker", "version", "--format", "{{.Server.Arch}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"arm64": "aarch64-unknown-linux-musl",
|
||||||
|
"amd64": "x86_64-unknown-linux-musl",
|
||||||
|
}[arch]
|
||||||
|
except KeyError:
|
||||||
|
raise SystemExit(f"unsupported Docker architecture: {arch!r}") from None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_agent_binaries() -> Path:
|
||||||
|
"""Cross-build the static Linux agent stack once, cached in .benchmark/.
|
||||||
|
|
||||||
|
The agents run *inside* each Harbor task container as the real
|
||||||
|
buzz-acp → buzz-agent → buzz-dev-mcp stack, so the binaries must be
|
||||||
|
Linux ELF for the task image architecture. musl-static means they run
|
||||||
|
on any Linux base image (glibc or not). The relay loopback forwarder
|
||||||
|
is compiled in the same step with plain rustc (std-only, no deps).
|
||||||
|
"""
|
||||||
|
triple = linux_triple()
|
||||||
|
bin_dir = LINUX_TARGET_DIR / triple / "release"
|
||||||
|
targets = AGENT_BINARIES + (FORWARDER_BINARY,)
|
||||||
|
if all((bin_dir / name).is_file() for name in targets):
|
||||||
|
return bin_dir
|
||||||
|
print(
|
||||||
|
f"Linux agent binaries missing — cross-building for {triple} "
|
||||||
|
f"in {RUST_IMAGE} (first run only, ~2 min)..."
|
||||||
|
)
|
||||||
|
LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(STATE_DIR / "cargo-registry").mkdir(exist_ok=True)
|
||||||
|
packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)]
|
||||||
|
forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
f"{REPO_ROOT}:/src:ro",
|
||||||
|
"-v",
|
||||||
|
f"{LINUX_TARGET_DIR}:/target",
|
||||||
|
"-v",
|
||||||
|
f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry",
|
||||||
|
"-e",
|
||||||
|
"CARGO_TARGET_DIR=/target",
|
||||||
|
"-w",
|
||||||
|
"/src",
|
||||||
|
RUST_IMAGE,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"apk add --no-cache musl-dev >/dev/null && "
|
||||||
|
f"cargo build --release --locked --target {triple} "
|
||||||
|
+ " ".join(packages)
|
||||||
|
+ f" && rustc --edition 2021 -O --target {triple}"
|
||||||
|
f" -o /target/{triple}/release/{FORWARDER_BINARY}"
|
||||||
|
f" /src/{forwarder_src}",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
missing = [n for n in targets if not (bin_dir / n).is_file()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"cross-build produced no {', '.join(missing)} in {bin_dir}")
|
||||||
|
return bin_dir
|
||||||
|
|
||||||
|
|
||||||
|
# -- GUI ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def launch_gui(state: dict[str, str]) -> subprocess.Popen:
|
||||||
|
"""Open the Buzz desktop app logged in as the benchmark user.
|
||||||
|
|
||||||
|
The relay runs closed (membership required), so the user pubkey is first
|
||||||
|
added to the relay membership list via buzz-admin inside the container —
|
||||||
|
NIP-OA auth tags cover the agents, but the GUI authenticates as a plain
|
||||||
|
member, exactly like a human.
|
||||||
|
"""
|
||||||
|
subprocess.run(
|
||||||
|
compose_command(
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"relay",
|
||||||
|
"buzz-admin",
|
||||||
|
"add-member",
|
||||||
|
"--pubkey",
|
||||||
|
state["user_pubkey"],
|
||||||
|
),
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
desktop_dir = REPO_ROOT / "desktop"
|
||||||
|
if not (desktop_dir / "node_modules").is_dir():
|
||||||
|
subprocess.run(["pnpm", "install"], cwd=desktop_dir, check=True)
|
||||||
|
|
||||||
|
# tauri dev needs sidecar files present; stub them and drop in the real
|
||||||
|
# CLI binary (mirrors the just staging recipe).
|
||||||
|
target = subprocess.run(
|
||||||
|
["rustc", "-vV"], capture_output=True, text=True, check=True
|
||||||
|
).stdout
|
||||||
|
triple = next(
|
||||||
|
line.split(": ", 1)[1]
|
||||||
|
for line in target.splitlines()
|
||||||
|
if line.startswith("host: ")
|
||||||
|
)
|
||||||
|
sidecar_dir = desktop_dir / "src-tauri" / "binaries"
|
||||||
|
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
binaries = ensure_binaries()
|
||||||
|
for name in (
|
||||||
|
"buzz-acp",
|
||||||
|
"buzz-agent",
|
||||||
|
"buzz-dev-mcp",
|
||||||
|
"git-credential-nostr",
|
||||||
|
"buzz",
|
||||||
|
):
|
||||||
|
stub = sidecar_dir / f"{name}-{triple}"
|
||||||
|
if not stub.exists():
|
||||||
|
stub.touch()
|
||||||
|
real_cli = sidecar_dir / f"buzz-{triple}"
|
||||||
|
real_cli.write_bytes(binaries["buzz"].read_bytes())
|
||||||
|
real_cli.chmod(0o755)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Opening Buzz GUI as the benchmark user ({state['user_pubkey'][:16]}…).\n"
|
||||||
|
"Watch, don't type — a message from you mid-trial would taint the run."
|
||||||
|
)
|
||||||
|
# Distinct bundle identifier: the desktop app persists workspaces (incl.
|
||||||
|
# their relay URLs) in per-identifier WebKit localStorage, and a stored
|
||||||
|
# workspace's relay URL overrides BUZZ_RELAY_URL by design. Reusing the
|
||||||
|
# default identifier means any past local-dev session's ws://localhost:3000
|
||||||
|
# workspace silently shadows the benchmark relay. An identifier of our own
|
||||||
|
# keeps that state isolated both ways.
|
||||||
|
tauri_config = json.dumps(
|
||||||
|
{"identifier": GUI_BUNDLE_IDENTIFIER, "productName": "Buzz Benchmark"}
|
||||||
|
)
|
||||||
|
return subprocess.Popen(
|
||||||
|
["pnpm", "exec", "tauri", "dev", "--config", tauri_config],
|
||||||
|
cwd=desktop_dir,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"BUZZ_RELAY_URL": f"ws://localhost:{RELAY_HTTP_PORT}",
|
||||||
|
"BUZZ_PRIVATE_KEY": state["user_secret_key"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- main ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def leaderboard_argv(
|
||||||
|
args: argparse.Namespace, provisioner_config: Path, agent_bin_dir: Path
|
||||||
|
) -> list[str]:
|
||||||
|
argv: list[str] = []
|
||||||
|
if args.path:
|
||||||
|
argv += ["--path", str(args.path)]
|
||||||
|
else:
|
||||||
|
argv += ["--dataset", args.dataset or DEFAULT_DATASET]
|
||||||
|
for pattern in args.include_task:
|
||||||
|
argv += ["--include-task", pattern]
|
||||||
|
for pattern in args.exclude_task:
|
||||||
|
argv += ["--exclude-task", pattern]
|
||||||
|
argv += [
|
||||||
|
"--attempts",
|
||||||
|
str(args.attempts),
|
||||||
|
"--manifest",
|
||||||
|
str(args.manifest),
|
||||||
|
"--endpoint-config",
|
||||||
|
str(args.endpoint_config),
|
||||||
|
"--provisioner-config",
|
||||||
|
str(provisioner_config),
|
||||||
|
"--agent-bin-dir",
|
||||||
|
str(agent_bin_dir),
|
||||||
|
# The relay as reachable from inside a task container: Docker's
|
||||||
|
# host alias, bridged to the canonical localhost address by the
|
||||||
|
# uploaded forwarder. Override the alias with
|
||||||
|
# BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host
|
||||||
|
# differently.
|
||||||
|
"--relay-gateway",
|
||||||
|
(
|
||||||
|
f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}"
|
||||||
|
f":{RELAY_HTTP_PORT}"
|
||||||
|
),
|
||||||
|
"--n-concurrent",
|
||||||
|
str(args.n_concurrent),
|
||||||
|
"--jobs-dir",
|
||||||
|
str(args.jobs_dir),
|
||||||
|
]
|
||||||
|
if args.job_name:
|
||||||
|
argv += ["--job-name", args.job_name]
|
||||||
|
if args.upload:
|
||||||
|
argv.append("--upload")
|
||||||
|
if args.dry_run:
|
||||||
|
argv.append("--dry-run")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
state = load_state()
|
||||||
|
print_user_identity(state)
|
||||||
|
write_env_file(state)
|
||||||
|
provisioner_config = write_provisioner_config(state, args.endpoint_config)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
agent_bin_dir = LINUX_TARGET_DIR / linux_triple() / "release"
|
||||||
|
else:
|
||||||
|
ensure_binaries()
|
||||||
|
agent_bin_dir = ensure_agent_binaries()
|
||||||
|
if args.fresh:
|
||||||
|
reset_environment()
|
||||||
|
ensure_stack(state)
|
||||||
|
if args.gui:
|
||||||
|
launch_gui(state)
|
||||||
|
|
||||||
|
return run_leaderboard.main(
|
||||||
|
leaderboard_argv(args, provisioner_config, agent_bin_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run a problem set with a team manifest and produce leaderboard-ready results.
|
||||||
|
|
||||||
|
One command wraps ``harbor run`` with only leaderboard-legal settings — no
|
||||||
|
timeout or resource overrides are accepted or forwarded, so the resulting job
|
||||||
|
directory passes Harbor's static validation as produced. After the run it
|
||||||
|
writes a ``metadata.yaml`` template derived from the manifest and prints the
|
||||||
|
exact upload/submit commands.
|
||||||
|
|
||||||
|
Run inside the testbed environment so ``harbor`` and the adapter are
|
||||||
|
importable:
|
||||||
|
|
||||||
|
uv run --project benchmarks/harbor-buzz-orchestra/testbed \
|
||||||
|
benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py \
|
||||||
|
--dataset terminal-bench/terminal-bench-2-1 \
|
||||||
|
--attempts 5 \
|
||||||
|
--manifest benchmarks/harbor-buzz-orchestra/manifests/<TEAM>.yaml \
|
||||||
|
--endpoint-config benchmarks/harbor-buzz-orchestra/testbed/endpoints/<ENDPOINTS>.json \
|
||||||
|
--provisioner-config <PROVISIONER.json> \
|
||||||
|
--agent-bin-dir <DIR with Linux buzz-acp/buzz-agent/buzz-dev-mcp>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
AGENT_IMPORT = "harbor_buzz_orchestra:BuzzOrchestraAgent"
|
||||||
|
PROVISIONER_FACTORY = "harbor_buzz_testbed:provisioner_from_dict"
|
||||||
|
# Host-side: the harness speaks to the relay as the trial user via this CLI.
|
||||||
|
BINARIES = ("buzz",)
|
||||||
|
# Container-side: the production stack uploaded into each task container.
|
||||||
|
# These must be Linux builds matching the task image architecture.
|
||||||
|
AGENT_BINARIES = ("buzz-acp", "buzz-agent", "buzz-dev-mcp")
|
||||||
|
# Uploaded alongside the stack when --relay-gateway is set: bridges the
|
||||||
|
# agents' canonical relay address to the host gateway (the relay is
|
||||||
|
# host-header tenant-bound, so agents must present its canonical Host).
|
||||||
|
FORWARDER_BINARY = "relay-forwarder"
|
||||||
|
|
||||||
|
PROVIDER_ORGS = {
|
||||||
|
"anthropic": "Anthropic",
|
||||||
|
"openai": "OpenAI",
|
||||||
|
"databricks": "Databricks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__.splitlines()[0],
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
problems = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
problems.add_argument(
|
||||||
|
"--dataset",
|
||||||
|
"-d",
|
||||||
|
help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)",
|
||||||
|
)
|
||||||
|
problems.add_argument(
|
||||||
|
"--path", "-p", type=Path, help="Local task or dataset directory"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-task",
|
||||||
|
"-i",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Task name to include from the dataset (glob, repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exclude-task",
|
||||||
|
"-x",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Task name to exclude from the dataset (glob, repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--attempts",
|
||||||
|
"-k",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
help="Runs per problem (leaderboards require 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--manifest", type=Path, required=True, help="Team manifest YAML"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--endpoint-config",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="JSON mapping manifest endpoint names to providers/API keys",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--provisioner-config",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="JSON config for the Buzz relay/Postgres provisioner",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--buzz-bin-dir",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Directory with the host buzz CLI (default: repo target/release, then target/debug)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--agent-bin-dir",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp "
|
||||||
|
"to upload into each task container",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--relay-gateway",
|
||||||
|
default="",
|
||||||
|
help="host:port of the benchmark relay as reachable from inside the "
|
||||||
|
"task container (e.g. host.docker.internal:3600). When set, a "
|
||||||
|
"loopback forwarder from --agent-bin-dir bridges the canonical "
|
||||||
|
"relay address to this gateway",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--n-concurrent", "-n", type=int, default=4, help="Concurrent trials"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--jobs-dir", type=Path, default=Path("jobs"), help="Job output root"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--job-name", default=None, help="Job name (default: lb-<condition>-<UTC>)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--upload",
|
||||||
|
action="store_true",
|
||||||
|
help="Upload to Harbor Hub when the job finishes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="Print the harbor command and exit"
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def find_binaries(bin_dir: Path | None) -> dict[str, Path]:
|
||||||
|
candidates = (
|
||||||
|
[bin_dir]
|
||||||
|
if bin_dir is not None
|
||||||
|
else [
|
||||||
|
PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
found = {name: candidate / name for name in BINARIES}
|
||||||
|
if all(path.is_file() for path in found.values()):
|
||||||
|
return found
|
||||||
|
searched = ", ".join(str(c) for c in candidates)
|
||||||
|
raise SystemExit(
|
||||||
|
f"buzz binaries not found (need {', '.join(BINARIES)}; searched {searched}). "
|
||||||
|
"Build them with `cargo build` or pass --buzz-bin-dir."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_agent_binaries(bin_dir: Path, with_forwarder: bool = False) -> dict[str, Path]:
|
||||||
|
"""The Linux agent stack uploaded into each task container."""
|
||||||
|
names = AGENT_BINARIES + ((FORWARDER_BINARY,) if with_forwarder else ())
|
||||||
|
found = {name: bin_dir / name for name in names}
|
||||||
|
missing = [name for name, path in found.items() if not path.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Linux agent binaries not found in {bin_dir}: {', '.join(missing)}. "
|
||||||
|
"`just benchmark` builds them; or cross-compile with "
|
||||||
|
"cargo --target <arch>-unknown-linux-musl and pass --agent-bin-dir."
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
binaries: dict[str, Path],
|
||||||
|
agent_binaries: dict[str, Path],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Compose the harbor invocation. Standard settings only: any timeout or
|
||||||
|
resource override would fail leaderboard static validation, so none are
|
||||||
|
accepted or forwarded."""
|
||||||
|
command = [
|
||||||
|
"harbor",
|
||||||
|
"run",
|
||||||
|
"--yes",
|
||||||
|
"--job-name",
|
||||||
|
args.job_name,
|
||||||
|
"--jobs-dir",
|
||||||
|
str(args.jobs_dir),
|
||||||
|
"-k",
|
||||||
|
str(args.attempts),
|
||||||
|
"--n-concurrent",
|
||||||
|
str(args.n_concurrent),
|
||||||
|
]
|
||||||
|
if args.dataset:
|
||||||
|
command += ["--dataset", args.dataset]
|
||||||
|
else:
|
||||||
|
command += ["--path", str(args.path)]
|
||||||
|
for pattern in args.include_task:
|
||||||
|
command += ["--include-task-name", pattern]
|
||||||
|
for pattern in args.exclude_task:
|
||||||
|
command += ["--exclude-task-name", pattern]
|
||||||
|
if args.upload:
|
||||||
|
command.append("--upload")
|
||||||
|
command += ["--agent", AGENT_IMPORT]
|
||||||
|
kwargs = {
|
||||||
|
"manifest": args.manifest,
|
||||||
|
"provisioner_factory": PROVISIONER_FACTORY,
|
||||||
|
"provisioner_config": args.provisioner_config,
|
||||||
|
"artifact_root": PACKAGE_ROOT,
|
||||||
|
"endpoint_config": args.endpoint_config,
|
||||||
|
"buzz_acp_binary": agent_binaries["buzz-acp"],
|
||||||
|
"buzz_agent_binary": agent_binaries["buzz-agent"],
|
||||||
|
"buzz_dev_mcp_binary": agent_binaries["buzz-dev-mcp"],
|
||||||
|
"buzz_cli_binary": binaries["buzz"],
|
||||||
|
"run_id": args.job_name,
|
||||||
|
}
|
||||||
|
if args.relay_gateway:
|
||||||
|
kwargs["relay_gateway"] = args.relay_gateway
|
||||||
|
kwargs["forwarder_binary"] = agent_binaries[FORWARDER_BINARY]
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
command += ["--agent-kwarg", f"{key}={value}"]
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def write_metadata_template(args: argparse.Namespace, job_dir: Path) -> Path:
|
||||||
|
"""Derive a metadata.yaml template (harbor.leaderboard.metadata schema)
|
||||||
|
from the manifest roster; display-name placeholders are for the submitter
|
||||||
|
to confirm."""
|
||||||
|
manifest = yaml.safe_load(args.manifest.read_text())
|
||||||
|
endpoints = json.loads(args.endpoint_config.read_text())
|
||||||
|
models, seen = [], set()
|
||||||
|
for entry in manifest.get("roster", []):
|
||||||
|
model = entry.get("model_revision") or entry["endpoint"]
|
||||||
|
if model in seen:
|
||||||
|
continue
|
||||||
|
seen.add(model)
|
||||||
|
provider = endpoints.get(entry["endpoint"], {}).get("provider", "FILL_ME")
|
||||||
|
models.append(
|
||||||
|
{
|
||||||
|
"model_name": model,
|
||||||
|
"model_provider": provider,
|
||||||
|
"model_display_name": model,
|
||||||
|
"model_org_display_name": PROVIDER_ORGS.get(provider, "FILL_ME"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"agent_url": "https://github.com/block/buzz",
|
||||||
|
"agent_display_name": f"Buzz Orchestra ({manifest.get('condition', 'team')})",
|
||||||
|
"agent_org_display_name": "Block",
|
||||||
|
"models": models,
|
||||||
|
}
|
||||||
|
path = job_dir / "metadata.yaml"
|
||||||
|
path.write_text(yaml.safe_dump(metadata, sort_keys=False))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
for label, path in (
|
||||||
|
("manifest", args.manifest),
|
||||||
|
("endpoint config", args.endpoint_config),
|
||||||
|
("provisioner config", args.provisioner_config),
|
||||||
|
):
|
||||||
|
if not path.is_file():
|
||||||
|
raise SystemExit(f"{label} not found: {path}")
|
||||||
|
if args.job_name is None:
|
||||||
|
condition = yaml.safe_load(args.manifest.read_text()).get("condition", "team")
|
||||||
|
stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
args.job_name = f"lb-{condition}-{stamp}"
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
# Dry runs print the command without requiring built binaries.
|
||||||
|
bin_dir = args.buzz_bin_dir or PACKAGE_ROOT.parents[1] / "target" / "release"
|
||||||
|
binaries = {name: bin_dir / name for name in BINARIES}
|
||||||
|
agent_binaries = {
|
||||||
|
name: args.agent_bin_dir / name
|
||||||
|
for name in AGENT_BINARIES + (FORWARDER_BINARY,)
|
||||||
|
}
|
||||||
|
print(" ".join(build_command(args, binaries, agent_binaries)))
|
||||||
|
return 0
|
||||||
|
binaries = find_binaries(args.buzz_bin_dir)
|
||||||
|
agent_binaries = find_agent_binaries(
|
||||||
|
args.agent_bin_dir, with_forwarder=bool(args.relay_gateway)
|
||||||
|
)
|
||||||
|
command = build_command(args, binaries, agent_binaries)
|
||||||
|
if shutil.which("harbor") is None:
|
||||||
|
raise SystemExit(
|
||||||
|
"harbor not on PATH — run via: uv run --project "
|
||||||
|
f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(command, check=False)
|
||||||
|
job_dir = args.jobs_dir / args.job_name
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}")
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
metadata_path = write_metadata_template(args, job_dir)
|
||||||
|
print("\nLeaderboard-ready job complete.")
|
||||||
|
print(f" 1. Review submitter details in {metadata_path}")
|
||||||
|
print(f" 2. harbor upload {job_dir}")
|
||||||
|
print(
|
||||||
|
" 3. harbor leaderboard submit -l terminal-bench/terminal-bench-2-1 "
|
||||||
|
f"-j <job UUID from upload> -m {metadata_path}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Buzz orchestra custom agent for Harbor."""
|
||||||
|
|
||||||
|
from .agent import BuzzOrchestraAgent
|
||||||
|
from .container_runtime import (
|
||||||
|
BuzzContainerRuntime,
|
||||||
|
EndpointLaunchConfig,
|
||||||
|
RuntimeLaunchError,
|
||||||
|
)
|
||||||
|
from .manifest import ExperimentManifest, ManifestError
|
||||||
|
from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
|
||||||
|
from .runtime import OrchestraRuntime, RuntimeResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentCredential",
|
||||||
|
"BuzzContainerRuntime",
|
||||||
|
"BuzzOrchestraAgent",
|
||||||
|
"EndpointLaunchConfig",
|
||||||
|
"ExperimentManifest",
|
||||||
|
"ManifestError",
|
||||||
|
"OrchestraRuntime",
|
||||||
|
"RuntimeLaunchError",
|
||||||
|
"RuntimeResult",
|
||||||
|
"TrialHandle",
|
||||||
|
"TrialProvisioner",
|
||||||
|
]
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Harbor custom-agent entry point for Buzz orchestration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from harbor.agents.base import BaseAgent
|
||||||
|
from harbor.environments.base import BaseEnvironment
|
||||||
|
from harbor.models.agent.context import AgentContext
|
||||||
|
|
||||||
|
from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
|
||||||
|
from .manifest import ExperimentManifest
|
||||||
|
from .provisioning import TrialProvisioner
|
||||||
|
from .runtime import OrchestraRuntime
|
||||||
|
|
||||||
|
|
||||||
|
class BuzzOrchestraAgent(BaseAgent):
|
||||||
|
"""Coordinate an arbitrary manifest-defined team through a Buzz trial."""
|
||||||
|
|
||||||
|
# Set True only once the runtime writes a validated agent/trajectory.json.
|
||||||
|
SUPPORTS_ATIF = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logs_dir: Path,
|
||||||
|
model_name: str | None = None,
|
||||||
|
*,
|
||||||
|
manifest: str | Path | dict[str, Any],
|
||||||
|
provisioner: TrialProvisioner | None = None,
|
||||||
|
runtime: OrchestraRuntime | None = None,
|
||||||
|
provisioner_factory: str | None = None,
|
||||||
|
provisioner_config: str | Path | dict[str, Any] | None = None,
|
||||||
|
artifact_root: str | Path | None = None,
|
||||||
|
endpoint_config: str | Path | dict[str, Any] | None = None,
|
||||||
|
buzz_acp_binary: str = "buzz-acp",
|
||||||
|
buzz_agent_binary: str = "buzz-agent",
|
||||||
|
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
|
||||||
|
buzz_cli_binary: str = "buzz",
|
||||||
|
relay_gateway: str = "",
|
||||||
|
forwarder_binary: str = "relay-forwarder",
|
||||||
|
run_id: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(logs_dir=logs_dir, model_name=model_name, **kwargs)
|
||||||
|
self.manifest = ExperimentManifest.load(manifest)
|
||||||
|
self.provisioner = provisioner or self._build_provisioner(
|
||||||
|
provisioner_factory, provisioner_config
|
||||||
|
)
|
||||||
|
self.runtime = runtime or self._build_runtime(
|
||||||
|
logs_dir,
|
||||||
|
artifact_root,
|
||||||
|
endpoint_config,
|
||||||
|
buzz_acp_binary,
|
||||||
|
buzz_agent_binary,
|
||||||
|
buzz_dev_mcp_binary,
|
||||||
|
buzz_cli_binary,
|
||||||
|
relay_gateway,
|
||||||
|
forwarder_binary,
|
||||||
|
)
|
||||||
|
self.run_id = run_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def name() -> str:
|
||||||
|
return "buzz-orchestra"
|
||||||
|
|
||||||
|
def version(self) -> str:
|
||||||
|
return "0.1.0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_mapping(
|
||||||
|
source: str | Path | dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
if isinstance(source, dict):
|
||||||
|
return source
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = Path(source).expanduser()
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"cannot load JSON config {path}: {error}") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TypeError(f"JSON config {path} must contain an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_provisioner(
|
||||||
|
cls,
|
||||||
|
factory_path: str | None,
|
||||||
|
config_source: str | Path | dict[str, Any] | None,
|
||||||
|
) -> TrialProvisioner | None:
|
||||||
|
config = cls._load_mapping(config_source)
|
||||||
|
if factory_path is None and config is None:
|
||||||
|
return None
|
||||||
|
if factory_path is None or config is None:
|
||||||
|
raise ValueError(
|
||||||
|
"provisioner_factory and provisioner_config must be provided together"
|
||||||
|
)
|
||||||
|
from harbor.utils.import_path import import_symbol
|
||||||
|
|
||||||
|
factory = import_symbol(factory_path)
|
||||||
|
return factory(config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_runtime(
|
||||||
|
cls,
|
||||||
|
logs_dir: Path,
|
||||||
|
artifact_root: str | Path | None,
|
||||||
|
endpoint_source: str | Path | dict[str, Any] | None,
|
||||||
|
buzz_acp_binary: str,
|
||||||
|
buzz_agent_binary: str,
|
||||||
|
buzz_dev_mcp_binary: str,
|
||||||
|
buzz_cli_binary: str,
|
||||||
|
relay_gateway: str,
|
||||||
|
forwarder_binary: str,
|
||||||
|
) -> OrchestraRuntime | None:
|
||||||
|
endpoint_data = cls._load_mapping(endpoint_source)
|
||||||
|
if endpoint_data is None and artifact_root is None:
|
||||||
|
return None
|
||||||
|
if endpoint_data is None or artifact_root is None:
|
||||||
|
raise ValueError(
|
||||||
|
"artifact_root and endpoint_config must be provided together"
|
||||||
|
)
|
||||||
|
endpoints = {
|
||||||
|
name: EndpointLaunchConfig(
|
||||||
|
provider=value["provider"],
|
||||||
|
api_key_env=value["api_key_env"],
|
||||||
|
env=value.get("env", {}),
|
||||||
|
)
|
||||||
|
for name, value in endpoint_data.items()
|
||||||
|
}
|
||||||
|
return BuzzContainerRuntime(
|
||||||
|
logs_dir=logs_dir,
|
||||||
|
artifact_root=Path(artifact_root),
|
||||||
|
endpoints=endpoints,
|
||||||
|
buzz_acp_binary=buzz_acp_binary,
|
||||||
|
buzz_agent_binary=buzz_agent_binary,
|
||||||
|
buzz_dev_mcp_binary=buzz_dev_mcp_binary,
|
||||||
|
buzz_cli_binary=buzz_cli_binary,
|
||||||
|
relay_gateway=relay_gateway,
|
||||||
|
forwarder_binary=forwarder_binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def setup(self, environment: BaseEnvironment) -> None:
|
||||||
|
"""Fail fast when the provisioner is configured but its stack is unhealthy."""
|
||||||
|
if self.provisioner is not None:
|
||||||
|
self.provisioner.healthcheck()
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
instruction: str,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
context: AgentContext,
|
||||||
|
) -> None:
|
||||||
|
if self.provisioner is None or self.runtime is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"BuzzOrchestraAgent requires provisioner and runtime integrations; "
|
||||||
|
"the adapter contract is installed but M1 wiring is incomplete"
|
||||||
|
)
|
||||||
|
|
||||||
|
context_id = self.context_id or environment.context_id
|
||||||
|
if context_id is None:
|
||||||
|
raise RuntimeError("Harbor context_id is required as the trial join key")
|
||||||
|
trial_id = str(context_id)
|
||||||
|
run_id = self.run_id or trial_id
|
||||||
|
# Human-readable channel label: the task short name, so a spectator
|
||||||
|
# GUI shows one recognisable channel per problem per attempt.
|
||||||
|
channel_label = getattr(environment, "environment_name", None)
|
||||||
|
handle = self.provisioner.create_trial(
|
||||||
|
run_id, trial_id, self.manifest, channel_label=channel_label
|
||||||
|
)
|
||||||
|
if handle.trial_id != trial_id:
|
||||||
|
raise RuntimeError("provisioner returned a handle for a different trial_id")
|
||||||
|
if handle.manifest_hash != self.manifest.sha256:
|
||||||
|
raise RuntimeError("provisioner returned a handle for a different manifest")
|
||||||
|
try:
|
||||||
|
result = await self.runtime.run(
|
||||||
|
instruction=instruction,
|
||||||
|
environment=environment,
|
||||||
|
manifest=self.manifest,
|
||||||
|
trial=handle,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.provisioner.teardown(handle)
|
||||||
|
|
||||||
|
context.n_input_tokens = result.input_tokens
|
||||||
|
context.n_cache_tokens = result.cached_input_tokens
|
||||||
|
context.n_output_tokens = result.output_tokens
|
||||||
|
context.cost_usd = result.cost_usd
|
||||||
|
context.metadata = {
|
||||||
|
**result.metadata,
|
||||||
|
"manifest_sha256": self.manifest.sha256,
|
||||||
|
"condition": self.manifest.condition,
|
||||||
|
"buzz_channel_id": handle.channel_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
"trial_id": trial_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
"""Run the production Buzz agent stack inside the Harbor task container.
|
||||||
|
|
||||||
|
Each provisioned identity is a full ``buzz-acp`` → ``buzz-agent`` →
|
||||||
|
``buzz-dev-mcp`` process tree launched *inside* the task container — the same
|
||||||
|
binaries and the same MCP toolset (shell, file tools, the ``buzz`` CLI on
|
||||||
|
PATH) that the desktop app gives a Buzz agent. The harness stays outside:
|
||||||
|
it provisions, uploads the pinned binaries, posts the task as the trial
|
||||||
|
user, and observes the channel until the orchestrator publishes DONE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from harbor.environments.base import BaseEnvironment
|
||||||
|
|
||||||
|
from .manifest import AgentClass, ExperimentManifest
|
||||||
|
from .provisioning import AgentCredential, TrialHandle
|
||||||
|
from .runtime import RuntimeResult
|
||||||
|
|
||||||
|
DEFAULT_MAX_AGENT_ROUNDS = 0 # 0 = unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial budget is the clock
|
||||||
|
# Container-side layout for the uploaded Buzz stack.
|
||||||
|
REMOTE_ROOT = "/opt/buzz"
|
||||||
|
REMOTE_BIN = f"{REMOTE_ROOT}/bin"
|
||||||
|
REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts"
|
||||||
|
REMOTE_LOGS = f"{REMOTE_ROOT}/logs"
|
||||||
|
# The relay is host-header tenant-bound (its community row is the authority
|
||||||
|
# of its own RELAY_URL), so agents must present that exact Host. When the
|
||||||
|
# relay actually lives outside the container, this forwarder listens on the
|
||||||
|
# canonical loopback address and bridges the byte stream to the gateway.
|
||||||
|
FORWARDER = f"{REMOTE_BIN}/relay-forwarder"
|
||||||
|
FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log"
|
||||||
|
# How many done-poll iterations between in-container liveness probes.
|
||||||
|
LIVENESS_EVERY = 10
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeLaunchError(RuntimeError):
|
||||||
|
"""Raised when a Buzz agent process cannot be launched or exits early."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EndpointLaunchConfig:
|
||||||
|
"""Deployment-specific environment needed to launch one manifest endpoint."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
api_key_env: str
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Agent:
|
||||||
|
credential: AgentCredential
|
||||||
|
pid: int
|
||||||
|
stdout_log: str # container path
|
||||||
|
stderr_log: str # container path
|
||||||
|
|
||||||
|
|
||||||
|
class BuzzContainerRuntime:
|
||||||
|
"""Launch one production Buzz agent stack per identity in the container."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
logs_dir: Path,
|
||||||
|
artifact_root: Path,
|
||||||
|
endpoints: dict[str, EndpointLaunchConfig],
|
||||||
|
buzz_acp_binary: str = "buzz-acp",
|
||||||
|
buzz_agent_binary: str = "buzz-agent",
|
||||||
|
buzz_dev_mcp_binary: str = "buzz-dev-mcp",
|
||||||
|
buzz_cli_binary: str = "buzz",
|
||||||
|
relay_gateway: str = "",
|
||||||
|
forwarder_binary: str = "relay-forwarder",
|
||||||
|
max_agent_rounds: int = DEFAULT_MAX_AGENT_ROUNDS,
|
||||||
|
readiness_timeout_seconds: float = 60.0,
|
||||||
|
poll_seconds: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
if max_agent_rounds < 0:
|
||||||
|
raise ValueError("max_agent_rounds must be >= 0 (0 = unbounded)")
|
||||||
|
if readiness_timeout_seconds <= 0:
|
||||||
|
raise ValueError("readiness_timeout_seconds must be positive")
|
||||||
|
self.logs_dir = Path(logs_dir)
|
||||||
|
self.artifact_root = Path(artifact_root)
|
||||||
|
self.endpoints = endpoints
|
||||||
|
# Linux builds uploaded into the task container:
|
||||||
|
self.buzz_acp_binary = buzz_acp_binary
|
||||||
|
self.buzz_agent_binary = buzz_agent_binary
|
||||||
|
self.buzz_dev_mcp_binary = buzz_dev_mcp_binary
|
||||||
|
# Host build used for user/provisioning operations only:
|
||||||
|
self.buzz_cli_binary = buzz_cli_binary
|
||||||
|
# Where the relay actually lives, as seen from inside the task
|
||||||
|
# container (e.g. host.docker.internal:3600). When set, a loopback
|
||||||
|
# forwarder bridges the agents' canonical relay address — the Host
|
||||||
|
# the relay's community row is bound to — to this gateway.
|
||||||
|
self.relay_gateway = relay_gateway
|
||||||
|
self.forwarder_binary = forwarder_binary
|
||||||
|
self.max_agent_rounds = max_agent_rounds
|
||||||
|
self.readiness_timeout_seconds = readiness_timeout_seconds
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
instruction: str,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
manifest: ExperimentManifest,
|
||||||
|
trial: TrialHandle,
|
||||||
|
) -> RuntimeResult:
|
||||||
|
classes = self._classes_by_agent_id(manifest, trial.credentials)
|
||||||
|
orchestrator = next(c for c in trial.credentials if c.role == "orchestrator")
|
||||||
|
workers = [c for c in trial.credentials if c.agent_id != orchestrator.agent_id]
|
||||||
|
if not workers:
|
||||||
|
raise RuntimeLaunchError("Buzz orchestration requires at least one worker")
|
||||||
|
trial_dir = self.logs_dir / "buzz"
|
||||||
|
trial_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
agents: list[_Agent] = []
|
||||||
|
infra: list[_Agent] = []
|
||||||
|
try:
|
||||||
|
await self._install_stack(environment)
|
||||||
|
forwarder = await self._start_forwarder(environment, trial)
|
||||||
|
if forwarder is not None:
|
||||||
|
infra.append(forwarder)
|
||||||
|
await self._buzz_json(
|
||||||
|
trial.user,
|
||||||
|
trial,
|
||||||
|
"users",
|
||||||
|
"set-profile",
|
||||||
|
"--name",
|
||||||
|
trial.user.agent_id,
|
||||||
|
)
|
||||||
|
for credential in trial.credentials:
|
||||||
|
await self._buzz_json(
|
||||||
|
credential,
|
||||||
|
trial,
|
||||||
|
"users",
|
||||||
|
"set-profile",
|
||||||
|
"--name",
|
||||||
|
credential.agent_id,
|
||||||
|
)
|
||||||
|
agents.append(
|
||||||
|
await self._launch_agent(
|
||||||
|
environment=environment,
|
||||||
|
trial=trial,
|
||||||
|
credential=credential,
|
||||||
|
agent_class=classes[credential.agent_id],
|
||||||
|
trial_dir=trial_dir,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self._wait_for_agents_ready(
|
||||||
|
environment, agents, trial.channel_id, infra
|
||||||
|
)
|
||||||
|
# The task arrives exactly as it would in production Buzz: a
|
||||||
|
# user prompt @mentioning the orchestrator. The harness never
|
||||||
|
# speaks as any agent. The orchestrator is mentioned by pubkey,
|
||||||
|
# not by name resolution: task text is untrusted payload, and any
|
||||||
|
# @-token inside it (e.g. Vim's `:%normal! @a`) would otherwise
|
||||||
|
# fail member resolution and kill the trial before the agent
|
||||||
|
# ever saw the task. An explicit --mention demotes unresolved
|
||||||
|
# @-tokens in the text to presentation-only.
|
||||||
|
await self._send(
|
||||||
|
trial.user,
|
||||||
|
trial,
|
||||||
|
f"@{orchestrator.agent_id} {instruction}",
|
||||||
|
mention=orchestrator.nostr_pubkey,
|
||||||
|
)
|
||||||
|
final_message = await asyncio.wait_for(
|
||||||
|
self._wait_for_done(environment, orchestrator, trial, agents + infra),
|
||||||
|
timeout=manifest.trial_budget.timeout_seconds,
|
||||||
|
)
|
||||||
|
await self._verify_m1_output(environment, manifest)
|
||||||
|
finally:
|
||||||
|
await self._stop_agents(environment, agents + infra)
|
||||||
|
await self._collect_logs(environment, trial_dir)
|
||||||
|
|
||||||
|
return RuntimeResult(
|
||||||
|
metadata={
|
||||||
|
"completion_message_id": final_message["id"],
|
||||||
|
"completion_message": final_message["content"],
|
||||||
|
"agent_runtime": "in-container",
|
||||||
|
"agent_hints_enabled": False,
|
||||||
|
"task_seed": "user-identity-prompt",
|
||||||
|
"agent_max_rounds": {
|
||||||
|
credential.agent_id: (
|
||||||
|
classes[credential.agent_id].budget.max_calls
|
||||||
|
or self.max_agent_rounds
|
||||||
|
)
|
||||||
|
for credential in trial.credentials
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- container setup ------------------------------------------------------
|
||||||
|
|
||||||
|
async def _install_stack(self, environment: BaseEnvironment) -> None:
|
||||||
|
"""Upload the pinned Linux binaries into the task container."""
|
||||||
|
uploads = {
|
||||||
|
f"{REMOTE_BIN}/buzz-acp": self.buzz_acp_binary,
|
||||||
|
f"{REMOTE_BIN}/buzz-agent": self.buzz_agent_binary,
|
||||||
|
f"{REMOTE_BIN}/buzz-dev-mcp": self.buzz_dev_mcp_binary,
|
||||||
|
}
|
||||||
|
if self.relay_gateway:
|
||||||
|
uploads[FORWARDER] = self.forwarder_binary
|
||||||
|
for source in uploads.values():
|
||||||
|
if not Path(source).is_file():
|
||||||
|
raise RuntimeLaunchError(f"agent binary not found: {source}")
|
||||||
|
result = await environment.exec(
|
||||||
|
f"mkdir -p {REMOTE_BIN} {REMOTE_PROMPTS} {REMOTE_LOGS}"
|
||||||
|
)
|
||||||
|
if result.return_code != 0:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"cannot create {REMOTE_ROOT} in the task container: "
|
||||||
|
f"{result.stderr or result.stdout}"
|
||||||
|
)
|
||||||
|
for target, source in uploads.items():
|
||||||
|
await environment.upload_file(source, target)
|
||||||
|
await environment.exec(f"chmod 0755 {REMOTE_BIN}/*")
|
||||||
|
|
||||||
|
async def _start_forwarder(
|
||||||
|
self, environment: BaseEnvironment, trial: TrialHandle
|
||||||
|
) -> _Agent | None:
|
||||||
|
"""Bridge the agents' canonical relay address to the real gateway.
|
||||||
|
|
||||||
|
The relay resolves its tenant from the request ``Host`` header, so the
|
||||||
|
agents must dial the exact authority its community row is bound to —
|
||||||
|
``trial.relay_ws_url``. When that address is loopback inside the task
|
||||||
|
container but the relay lives on the Docker host, this starts the
|
||||||
|
uploaded forwarder listening on the canonical address and pumping the
|
||||||
|
byte stream to ``relay_gateway``. Returns ``None`` when no gateway is
|
||||||
|
configured (the relay is reachable directly).
|
||||||
|
"""
|
||||||
|
if not self.relay_gateway:
|
||||||
|
return None
|
||||||
|
# Listen on the IPv4 loopback explicitly: binding the name `localhost`
|
||||||
|
# would pick whichever address family resolves first, while clients
|
||||||
|
# iterate both — pinning v4 makes the pair deterministic. The Host
|
||||||
|
# header the relay tenant-binds on comes from the URL the agents
|
||||||
|
# dial (trial.relay_ws_url), not from the socket address.
|
||||||
|
listen = self._ws_authority(trial.relay_ws_url).replace(
|
||||||
|
"localhost", "127.0.0.1", 1
|
||||||
|
)
|
||||||
|
log = FORWARDER_LOG
|
||||||
|
command = (
|
||||||
|
f"{shlex.quote(FORWARDER)} {shlex.quote(listen)} "
|
||||||
|
f"{shlex.quote(self.relay_gateway)} </dev/null "
|
||||||
|
f">{shlex.quote(log)} 2>&1 & echo $!"
|
||||||
|
)
|
||||||
|
result = await environment.exec(command)
|
||||||
|
try:
|
||||||
|
pid = int((result.stdout or "").strip().splitlines()[-1])
|
||||||
|
except (ValueError, IndexError) as error:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"cannot launch relay forwarder: {result.stderr or result.stdout}"
|
||||||
|
) from error
|
||||||
|
forwarder = _Agent(
|
||||||
|
AgentCredential(
|
||||||
|
agent_id="relay-forwarder",
|
||||||
|
role="infra",
|
||||||
|
nostr_secret_key="",
|
||||||
|
nostr_pubkey="",
|
||||||
|
nostr_auth_tag="",
|
||||||
|
llm_endpoint="",
|
||||||
|
llm_api_key="",
|
||||||
|
),
|
||||||
|
pid,
|
||||||
|
log,
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
|
||||||
|
while True:
|
||||||
|
probe = await environment.exec(f"cat {shlex.quote(log)} 2>/dev/null")
|
||||||
|
if "forwarding" in (probe.stdout or ""):
|
||||||
|
return forwarder
|
||||||
|
await self._raise_for_dead_agents(environment, [forwarder])
|
||||||
|
if asyncio.get_running_loop().time() >= deadline:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
"relay forwarder did not report readiness; "
|
||||||
|
f"see {log} in the trial artifacts"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self.poll_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ws_authority(relay_ws_url: str) -> str:
|
||||||
|
"""``host:port`` from a ws:// URL — the forwarder's listen address."""
|
||||||
|
if not relay_ws_url.startswith("ws://"):
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
"relay_gateway forwarding requires a ws:// relay_ws_url"
|
||||||
|
)
|
||||||
|
authority = relay_ws_url.removeprefix("ws://").split("/", 1)[0]
|
||||||
|
if ":" not in authority:
|
||||||
|
authority += ":80"
|
||||||
|
return authority
|
||||||
|
|
||||||
|
async def _launch_agent(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
trial: TrialHandle,
|
||||||
|
credential: AgentCredential,
|
||||||
|
agent_class: AgentClass,
|
||||||
|
trial_dir: Path,
|
||||||
|
) -> _Agent:
|
||||||
|
if not credential.llm_endpoint:
|
||||||
|
raise RuntimeLaunchError("credential llm_endpoint must not be empty")
|
||||||
|
endpoint = self.endpoints.get(credential.llm_endpoint)
|
||||||
|
if endpoint is None:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"no launch config for endpoint {credential.llm_endpoint!r}"
|
||||||
|
)
|
||||||
|
self._reject_identity_overrides(endpoint)
|
||||||
|
prompt_path = self.artifact_root / agent_class.prompt.path
|
||||||
|
self._verify_artifact(prompt_path, agent_class.prompt.sha256)
|
||||||
|
composed = self._compose_system_prompt(
|
||||||
|
trial_dir=trial_dir,
|
||||||
|
trial=trial,
|
||||||
|
credential=credential,
|
||||||
|
persona_path=prompt_path,
|
||||||
|
)
|
||||||
|
remote_prompt = f"{REMOTE_PROMPTS}/{credential.agent_id}.system-prompt.md"
|
||||||
|
await environment.upload_file(composed, remote_prompt)
|
||||||
|
|
||||||
|
stdout_log = f"{REMOTE_LOGS}/{credential.agent_id}.stdout.log"
|
||||||
|
stderr_log = f"{REMOTE_LOGS}/{credential.agent_id}.stderr.log"
|
||||||
|
env = self._agent_env(
|
||||||
|
trial=trial,
|
||||||
|
credential=credential,
|
||||||
|
agent_class=agent_class,
|
||||||
|
endpoint=endpoint,
|
||||||
|
remote_prompt=remote_prompt,
|
||||||
|
)
|
||||||
|
command = (
|
||||||
|
f"{shlex.quote(f'{REMOTE_BIN}/buzz-acp')} </dev/null "
|
||||||
|
f">{shlex.quote(stdout_log)} 2>{shlex.quote(stderr_log)} & echo $!"
|
||||||
|
)
|
||||||
|
result = await environment.exec(command, env=env)
|
||||||
|
try:
|
||||||
|
pid = int((result.stdout or "").strip().splitlines()[-1])
|
||||||
|
except (ValueError, IndexError) as error:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"cannot launch agent {credential.agent_id}: "
|
||||||
|
f"{result.stderr or result.stdout}"
|
||||||
|
) from error
|
||||||
|
return _Agent(credential, pid, stdout_log, stderr_log)
|
||||||
|
|
||||||
|
def _agent_env(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
trial: TrialHandle,
|
||||||
|
credential: AgentCredential,
|
||||||
|
agent_class: AgentClass,
|
||||||
|
endpoint: EndpointLaunchConfig,
|
||||||
|
remote_prompt: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""The desktop-launch environment: real acp/agent/dev-mcp wiring."""
|
||||||
|
return {
|
||||||
|
**endpoint.env,
|
||||||
|
"BUZZ_RELAY_URL": trial.relay_ws_url,
|
||||||
|
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
|
||||||
|
# Desktop parity: the GUI also sets NOSTR_PRIVATE_KEY on buzz-acp
|
||||||
|
# so buzz-dev-mcp's shim can wire git auth/signing for the agent.
|
||||||
|
"NOSTR_PRIVATE_KEY": credential.nostr_secret_key,
|
||||||
|
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
|
||||||
|
"BUZZ_ACP_AGENT_COMMAND": f"{REMOTE_BIN}/buzz-agent",
|
||||||
|
"BUZZ_ACP_AGENT_ARGS": "",
|
||||||
|
"BUZZ_ACP_MCP_COMMAND": f"{REMOTE_BIN}/buzz-dev-mcp",
|
||||||
|
"BUZZ_ACP_CHANNELS": trial.channel_id,
|
||||||
|
"BUZZ_ACP_SUBSCRIBE": "mentions",
|
||||||
|
"BUZZ_ACP_RESPOND_TO": "anyone",
|
||||||
|
"BUZZ_ACP_NO_MEMORY": "true",
|
||||||
|
"BUZZ_ACP_SYSTEM_PROMPT_FILE": remote_prompt,
|
||||||
|
"BUZZ_AGENT_PROVIDER": endpoint.provider,
|
||||||
|
"BUZZ_AGENT_MODEL": credential.llm_endpoint,
|
||||||
|
"BUZZ_AGENT_MAX_OUTPUT_TOKENS": str(
|
||||||
|
agent_class.generation.max_output_tokens
|
||||||
|
),
|
||||||
|
"BUZZ_AGENT_MAX_CONTEXT_TOKENS": str(
|
||||||
|
agent_class.generation.context_window_tokens
|
||||||
|
),
|
||||||
|
"BUZZ_AGENT_MAX_ROUNDS": str(
|
||||||
|
agent_class.budget.max_calls or self.max_agent_rounds
|
||||||
|
),
|
||||||
|
# The pinned persona is the whole prompt: no hint-file or skill
|
||||||
|
# discovery from the task filesystem (metadata reports this).
|
||||||
|
"BUZZ_AGENT_NO_HINTS": "1",
|
||||||
|
endpoint.api_key_env: credential.llm_api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- lifecycle -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _wait_for_agents_ready(
|
||||||
|
self,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
agents: list[_Agent],
|
||||||
|
channel_id: str,
|
||||||
|
infra: list[_Agent] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Wait until every ACP process confirms its trial-channel subscription."""
|
||||||
|
marker = f"subscribed to channel {channel_id}"
|
||||||
|
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
|
||||||
|
pending = {agent.credential.agent_id: agent for agent in agents}
|
||||||
|
while pending:
|
||||||
|
await self._raise_for_dead_agents(environment, agents + (infra or []))
|
||||||
|
for agent_id, agent in list(pending.items()):
|
||||||
|
result = await environment.exec(
|
||||||
|
f"cat {shlex.quote(agent.stdout_log)} "
|
||||||
|
f"{shlex.quote(agent.stderr_log)} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if marker in (result.stdout or ""):
|
||||||
|
del pending[agent_id]
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
if asyncio.get_running_loop().time() >= deadline:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
"agents did not subscribe to trial channel before readiness "
|
||||||
|
f"timeout: {sorted(pending)}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self.poll_seconds)
|
||||||
|
|
||||||
|
async def _wait_for_done(
|
||||||
|
self,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
orchestrator: AgentCredential,
|
||||||
|
trial: TrialHandle,
|
||||||
|
agents: list[_Agent],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Observe the channel as the trial user until the orchestrator posts DONE.
|
||||||
|
|
||||||
|
Observation only: the harness never speaks as any agent. If the team
|
||||||
|
stalls, the trial times out and the stall is the measured result.
|
||||||
|
"""
|
||||||
|
polls = 0
|
||||||
|
while True:
|
||||||
|
if polls % LIVENESS_EVERY == 0:
|
||||||
|
await self._raise_for_dead_agents(environment, agents)
|
||||||
|
polls += 1
|
||||||
|
messages = await self._buzz_json(
|
||||||
|
trial.user,
|
||||||
|
trial,
|
||||||
|
"messages",
|
||||||
|
"get",
|
||||||
|
"--channel",
|
||||||
|
trial.channel_id,
|
||||||
|
"--limit",
|
||||||
|
"100",
|
||||||
|
)
|
||||||
|
for message in messages:
|
||||||
|
if message.get("pubkey") == orchestrator.nostr_pubkey and str(
|
||||||
|
message.get("content", "")
|
||||||
|
).startswith("DONE:"):
|
||||||
|
return message
|
||||||
|
await asyncio.sleep(self.poll_seconds)
|
||||||
|
|
||||||
|
async def _raise_for_dead_agents(
|
||||||
|
self, environment: BaseEnvironment, agents: list[_Agent]
|
||||||
|
) -> None:
|
||||||
|
if not agents:
|
||||||
|
return
|
||||||
|
probes = "; ".join(
|
||||||
|
f"kill -0 {agent.pid} 2>/dev/null || echo DEAD:{agent.credential.agent_id}"
|
||||||
|
for agent in agents
|
||||||
|
)
|
||||||
|
result = await environment.exec(probes)
|
||||||
|
dead = [
|
||||||
|
line.removeprefix("DEAD:")
|
||||||
|
for line in (result.stdout or "").splitlines()
|
||||||
|
if line.startswith("DEAD:")
|
||||||
|
]
|
||||||
|
if dead:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"agent processes exited early: {sorted(dead)}; "
|
||||||
|
f"see {REMOTE_LOGS} in the trial artifacts"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _stop_agents(environment: BaseEnvironment, agents: list[_Agent]) -> None:
|
||||||
|
"""Terminate every process of the uploaded stack (acp, agent, mcp)."""
|
||||||
|
if not agents:
|
||||||
|
return
|
||||||
|
# Match by cmdline prefix via /proc: pkill/procps is not guaranteed
|
||||||
|
# to exist in task images, the /proc filesystem is.
|
||||||
|
sweep = (
|
||||||
|
"for d in /proc/[0-9]*; do "
|
||||||
|
f'grep -aq {REMOTE_BIN} "$d/cmdline" 2>/dev/null '
|
||||||
|
'&& kill -TERM "${d#/proc/}" 2>/dev/null; done; true'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await environment.exec(sweep)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
await environment.exec(sweep.replace("-TERM", "-KILL"))
|
||||||
|
except Exception: # noqa: S110, BLE001 — environment may already be gone
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _collect_logs(
|
||||||
|
self, environment: BaseEnvironment, trial_dir: Path
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await environment.download_dir(REMOTE_LOGS, trial_dir)
|
||||||
|
except Exception: # noqa: S110, BLE001 — best effort; env may be torn down
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- Buzz CLI as the trial user / provisioning identities -------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _verify_m1_output(
|
||||||
|
environment: BaseEnvironment, manifest: ExperimentManifest
|
||||||
|
) -> None:
|
||||||
|
"""Fail M1 immediately unless the artifact satisfies the grader contract."""
|
||||||
|
if manifest.condition != "M1-hello-world":
|
||||||
|
return
|
||||||
|
result = await environment.exec(
|
||||||
|
'python3 -c "from pathlib import Path; '
|
||||||
|
"p = Path('/app/hello.txt'); "
|
||||||
|
"assert p.is_file() and p.read_text().strip() == 'Hello, world!'\""
|
||||||
|
)
|
||||||
|
if result.return_code != 0:
|
||||||
|
detail = (
|
||||||
|
result.stderr or result.stdout or "grader-equivalent check failed"
|
||||||
|
).strip()
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
"M1 pre-verifier sanity probe failed: /app/hello.txt must exist "
|
||||||
|
f"and its stripped text must equal 'Hello, world!' ({detail})"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send(
|
||||||
|
self,
|
||||||
|
credential: AgentCredential,
|
||||||
|
trial: TrialHandle,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
mention: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
args = [
|
||||||
|
"messages",
|
||||||
|
"send",
|
||||||
|
"--channel",
|
||||||
|
trial.channel_id,
|
||||||
|
"--content",
|
||||||
|
content,
|
||||||
|
]
|
||||||
|
if mention is not None:
|
||||||
|
args += ["--mention", mention]
|
||||||
|
await self._buzz_json(credential, trial, *args)
|
||||||
|
|
||||||
|
async def _buzz_json(
|
||||||
|
self, credential: AgentCredential, trial: TrialHandle, *args: str
|
||||||
|
) -> Any:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
self.buzz_cli_binary,
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"BUZZ_RELAY_URL": self._user_relay_url(trial),
|
||||||
|
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
|
||||||
|
"BUZZ_AUTH_TAG": credential.nostr_auth_tag,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"buzz {shlex.join(args)} exited {process.returncode}: "
|
||||||
|
f"{stderr.decode(errors='replace').strip()}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise RuntimeLaunchError("buzz returned invalid JSON") from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_relay_url(trial: TrialHandle) -> str:
|
||||||
|
"""The relay as reachable from the HOST (user identity, harness).
|
||||||
|
|
||||||
|
``trial.relay_ws_url`` is the container view (the agents' runtime);
|
||||||
|
``trial.user_relay_url`` is the host view. Fall back to deriving an
|
||||||
|
http URL from the ws URL for handles minted before v1.2.
|
||||||
|
"""
|
||||||
|
if trial.user_relay_url:
|
||||||
|
return trial.user_relay_url
|
||||||
|
return BuzzContainerRuntime._cli_relay_url(trial.relay_ws_url)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cli_relay_url(relay_ws_url: str) -> str:
|
||||||
|
if relay_ws_url.startswith("ws://"):
|
||||||
|
return f"http://{relay_ws_url.removeprefix('ws://')}"
|
||||||
|
if relay_ws_url.startswith("wss://"):
|
||||||
|
return f"https://{relay_ws_url.removeprefix('wss://')}"
|
||||||
|
raise RuntimeLaunchError("trial relay_ws_url must use ws:// or wss://")
|
||||||
|
|
||||||
|
# -- manifest plumbing -------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classes_by_agent_id(
|
||||||
|
manifest: ExperimentManifest, credentials: tuple[AgentCredential, ...]
|
||||||
|
) -> dict[str, AgentClass]:
|
||||||
|
by_id = {entry.id: entry for entry in manifest.roster}
|
||||||
|
result: dict[str, AgentClass] = {}
|
||||||
|
for credential in credentials:
|
||||||
|
class_id, separator, index = credential.agent_id.rpartition("-")
|
||||||
|
match = by_id.get(class_id)
|
||||||
|
if not separator or not index.isdigit() or match is None:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"credential {credential.agent_id!r} does not match a roster class"
|
||||||
|
)
|
||||||
|
if credential.role != match.kind:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"credential {credential.agent_id!r} role does not match manifest"
|
||||||
|
)
|
||||||
|
result[credential.agent_id] = match
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_artifact(path: Path, expected_sha256: str) -> None:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
try:
|
||||||
|
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
except OSError as error:
|
||||||
|
raise RuntimeLaunchError(f"cannot read prompt {path}: {error}") from error
|
||||||
|
if actual != expected_sha256:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"prompt hash mismatch for {path}: expected {expected_sha256}, got {actual}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compose_system_prompt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
trial_dir: Path,
|
||||||
|
trial: TrialHandle,
|
||||||
|
credential: AgentCredential,
|
||||||
|
persona_path: Path,
|
||||||
|
) -> Path:
|
||||||
|
"""Append the trial's team roster to the pinned persona.
|
||||||
|
|
||||||
|
The analogue of a production Buzz workspace's team context: each agent
|
||||||
|
knows its own identity, its channel, the user it reports to, and its
|
||||||
|
teammates' names, pubkeys, and roles from its system prompt — it never
|
||||||
|
has to discover them over the relay.
|
||||||
|
"""
|
||||||
|
persona = persona_path.read_text(encoding="utf-8")
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"## Your team",
|
||||||
|
"",
|
||||||
|
f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).",
|
||||||
|
f"The team coordinates in Buzz channel `{trial.channel_id}`.",
|
||||||
|
(
|
||||||
|
f"Tasks come from the user `{trial.user.agent_id}` "
|
||||||
|
f"(pubkey `{trial.user.nostr_pubkey}`); address your final report "
|
||||||
|
"to them."
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"| Name | Role | Pubkey |",
|
||||||
|
"|------|------|--------|",
|
||||||
|
]
|
||||||
|
for teammate in trial.credentials:
|
||||||
|
if teammate.agent_id == credential.agent_id:
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f"| {teammate.agent_id} | {teammate.role} | `{teammate.nostr_pubkey}` |"
|
||||||
|
)
|
||||||
|
composed = persona + "\n".join(lines) + "\n"
|
||||||
|
path = trial_dir / f"{credential.agent_id}.system-prompt.md"
|
||||||
|
path.write_text(composed, encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reject_identity_overrides(endpoint: EndpointLaunchConfig) -> None:
|
||||||
|
forbidden = {
|
||||||
|
"BUZZ_RELAY_URL",
|
||||||
|
"BUZZ_PRIVATE_KEY",
|
||||||
|
"BUZZ_AUTH_TAG",
|
||||||
|
"BUZZ_ACP_CHANNELS",
|
||||||
|
"BUZZ_ACP_MCP_COMMAND",
|
||||||
|
"BUZZ_ACP_AGENT_COMMAND",
|
||||||
|
}
|
||||||
|
overlap = forbidden & endpoint.env.keys()
|
||||||
|
if overlap:
|
||||||
|
raise RuntimeLaunchError(
|
||||||
|
f"endpoint env cannot override trial identity: {sorted(overlap)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Validated, content-addressed experiment manifests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, Self
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestError(ValueError):
|
||||||
|
"""Raised when a manifest cannot be loaded or validated."""
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModel(BaseModel):
|
||||||
|
"""Base model that rejects misspelled or unrecognised manifest fields."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRef(StrictModel):
|
||||||
|
"""Reference to immutable prompt, persona, or skill content."""
|
||||||
|
|
||||||
|
path: str = Field(min_length=1)
|
||||||
|
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationConfig(StrictModel):
|
||||||
|
"""Model generation controls frozen for a condition."""
|
||||||
|
|
||||||
|
temperature: float = Field(default=0.0, ge=0.0)
|
||||||
|
max_output_tokens: int = Field(gt=0)
|
||||||
|
context_window_tokens: int = Field(gt=0)
|
||||||
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBudget(StrictModel):
|
||||||
|
"""Optional per-agent live safety limits."""
|
||||||
|
|
||||||
|
max_calls: int | None = Field(default=None, gt=0)
|
||||||
|
max_input_tokens: int | None = Field(default=None, gt=0)
|
||||||
|
max_output_tokens: int | None = Field(default=None, gt=0)
|
||||||
|
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Price(StrictModel):
|
||||||
|
"""Frozen USD rates for one endpoint revision, per million tokens."""
|
||||||
|
|
||||||
|
input_per_million_usd: float = Field(ge=0)
|
||||||
|
cached_input_per_million_usd: float = Field(ge=0)
|
||||||
|
output_per_million_usd: float = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentClass(StrictModel):
|
||||||
|
"""A homogeneous class of agents in the trial roster."""
|
||||||
|
|
||||||
|
id: str = Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9._-]*$")
|
||||||
|
kind: Literal["orchestrator", "worker"]
|
||||||
|
role: str = Field(min_length=1)
|
||||||
|
count: int = Field(gt=0)
|
||||||
|
endpoint: str = Field(min_length=1)
|
||||||
|
model_revision: str = Field(min_length=1)
|
||||||
|
prompt: ArtifactRef
|
||||||
|
persona: ArtifactRef | None = None
|
||||||
|
skills: tuple[ArtifactRef, ...] = ()
|
||||||
|
generation: GenerationConfig
|
||||||
|
budget: AgentBudget = AgentBudget()
|
||||||
|
concurrency: int = Field(default=1, gt=0)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_concurrency(self) -> Self:
|
||||||
|
if self.concurrency > self.count:
|
||||||
|
raise ValueError("concurrency cannot exceed count")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBudget(StrictModel):
|
||||||
|
"""Trial-wide hard limits enforced by the live runtime, not async receipts."""
|
||||||
|
|
||||||
|
timeout_seconds: int = Field(gt=0)
|
||||||
|
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentManifest(StrictModel):
|
||||||
|
"""Complete immutable input defining one benchmark condition."""
|
||||||
|
|
||||||
|
schema_version: Literal["1"] = "1"
|
||||||
|
condition: str = Field(min_length=1)
|
||||||
|
roster: tuple[AgentClass, ...] = Field(min_length=1)
|
||||||
|
prices: dict[str, Price]
|
||||||
|
trial_budget: TrialBudget
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_roster(self) -> Self:
|
||||||
|
ids = [entry.id for entry in self.roster]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("roster class ids must be unique")
|
||||||
|
orchestrators = sum(
|
||||||
|
entry.count for entry in self.roster if entry.kind == "orchestrator"
|
||||||
|
)
|
||||||
|
if orchestrators != 1:
|
||||||
|
raise ValueError("roster must contain exactly one orchestrator")
|
||||||
|
endpoints = {entry.endpoint for entry in self.roster}
|
||||||
|
missing_prices = sorted(endpoints - self.prices.keys())
|
||||||
|
if missing_prices:
|
||||||
|
raise ValueError(f"prices missing for endpoints: {missing_prices}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
"""Return stable UTF-8 JSON independent of YAML formatting and key order."""
|
||||||
|
data = self.model_dump(mode="json", exclude_none=False)
|
||||||
|
return json.dumps(
|
||||||
|
data,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sha256(self) -> str:
|
||||||
|
"""Return the condition identity derived from canonical manifest content."""
|
||||||
|
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, source: str | Path | dict[str, Any]) -> Self:
|
||||||
|
"""Load a manifest from a YAML/JSON file or an already-decoded mapping."""
|
||||||
|
if isinstance(source, dict):
|
||||||
|
raw = source
|
||||||
|
else:
|
||||||
|
path = Path(source).expanduser()
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, yaml.YAMLError) as error:
|
||||||
|
raise ManifestError(f"cannot load manifest {path}: {error}") from error
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ManifestError("manifest root must be a mapping")
|
||||||
|
try:
|
||||||
|
return cls.model_validate(raw)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ManifestError(f"invalid manifest: {error}") from error
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Typed boundary between the Harbor adapter and Buzz trial provisioning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from .manifest import ExperimentManifest
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AgentCredential:
|
||||||
|
"""One trial-scoped Buzz identity and attributed LLM credential."""
|
||||||
|
|
||||||
|
agent_id: str
|
||||||
|
role: str
|
||||||
|
nostr_secret_key: str
|
||||||
|
nostr_pubkey: str
|
||||||
|
nostr_auth_tag: str
|
||||||
|
llm_endpoint: str
|
||||||
|
llm_api_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrialHandle:
|
||||||
|
"""Provisioned Buzz resources owned by one Harbor trial."""
|
||||||
|
|
||||||
|
run_id: str
|
||||||
|
trial_id: str
|
||||||
|
manifest_hash: str
|
||||||
|
relay_ws_url: str
|
||||||
|
channel_id: str
|
||||||
|
credentials: tuple[AgentCredential, ...]
|
||||||
|
# The trial's human-analogue identity: owns the channel, posts the task
|
||||||
|
# as a user prompt, and receives the final report. Never runs an agent
|
||||||
|
# process, so its llm_endpoint and llm_api_key are empty strings.
|
||||||
|
user: AgentCredential
|
||||||
|
# v1.2 (additive): the relay as reachable from the HOST, where the user
|
||||||
|
# identity and the harness run. ``relay_ws_url`` is the view from the
|
||||||
|
# agents' runtime (the task container). Empty means both views coincide.
|
||||||
|
user_relay_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TrialProvisioner(Protocol):
|
||||||
|
"""Creates and tears down trial-isolated Buzz resources synchronously."""
|
||||||
|
|
||||||
|
def create_trial(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
trial_id: str,
|
||||||
|
manifest: ExperimentManifest,
|
||||||
|
channel_label: str | None = None,
|
||||||
|
) -> TrialHandle: ...
|
||||||
|
|
||||||
|
def teardown(self, handle: TrialHandle) -> None: ...
|
||||||
|
|
||||||
|
def healthcheck(self) -> None: ...
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runtime contract kept separate from Buzz resource provisioning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from harbor.environments.base import BaseEnvironment
|
||||||
|
|
||||||
|
from .manifest import ExperimentManifest
|
||||||
|
from .provisioning import TrialHandle
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeResult:
|
||||||
|
"""Aggregate values returned by an orchestration runtime."""
|
||||||
|
|
||||||
|
input_tokens: int = 0
|
||||||
|
cached_input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cost_usd: float = 0.0
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestraRuntime(Protocol):
|
||||||
|
"""Runs the provisioned roster and writes trial artifacts into logs_dir."""
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
instruction: str,
|
||||||
|
environment: BaseEnvironment,
|
||||||
|
manifest: ExperimentManifest,
|
||||||
|
trial: TrialHandle,
|
||||||
|
) -> RuntimeResult: ...
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Benchmark-only overrides for deploy/compose/compose.yml.
|
||||||
|
#
|
||||||
|
# The production bundle stays untouched; this file publishes what the
|
||||||
|
# benchmark harness needs on the host:
|
||||||
|
# - relay Prometheus metrics (:9102) for scraping during runs
|
||||||
|
# - Postgres (:5433) for the provisioner and receipts ingestion
|
||||||
|
# (5433 to avoid colliding with other local Postgres instances)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --env-file .env \
|
||||||
|
# -f compose.yml -f <this file> up -d --wait
|
||||||
|
services:
|
||||||
|
relay:
|
||||||
|
ports:
|
||||||
|
- "${BUZZ_METRICS_HOST_PORT:-9102}:9102"
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "${BUZZ_PG_HOST_PORT:-5433}:5432"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Endpoint launch configs
|
||||||
|
|
||||||
|
Deployment-time mapping from manifest endpoint names to
|
||||||
|
`EndpointLaunchConfig` (`provider` / `api_key_env` / `env`), passed to
|
||||||
|
`harbor run` as `--agent-kwarg endpoint_config=<path>`. This is deployment
|
||||||
|
config, deliberately OUTSIDE the immutable condition manifest — the manifest
|
||||||
|
endpoint string remains the join key.
|
||||||
|
|
||||||
|
Every key in these files must be a manifest endpoint name; the loader treats
|
||||||
|
all entries as endpoint configs (no comment keys).
|
||||||
|
|
||||||
|
## m1-local.json
|
||||||
|
|
||||||
|
M1 wiring proof: both placeholder endpoints resolve to one local llama-server
|
||||||
|
(OpenAI-compatible, `http://127.0.0.1:8091/v1`, no cloud keys).
|
||||||
|
|
||||||
|
buzz-agent env contract (crates/buzz-agent/src/config.rs, pinned at the M1
|
||||||
|
binary SHA): `provider=openai` reads `OPENAI_COMPAT_API_KEY` +
|
||||||
|
`OPENAI_COMPAT_BASE_URL`; the runtime sets `BUZZ_AGENT_MODEL` from the
|
||||||
|
manifest endpoint name, which overrides `OPENAI_COMPAT_MODEL` — llama-server
|
||||||
|
ignores the model name, so the placeholder value is harmless there.
|
||||||
|
llama-server needs no real key; the provisioner's per-endpoint
|
||||||
|
`llm_api_keys` map supplies a dummy value.
|
||||||
|
|
||||||
|
The Databricks pilot config is the same file shape with real serving
|
||||||
|
endpoint hosts/keys.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"claude-sonnet-4-6": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"api_key_env": "ANTHROPIC_API_KEY",
|
||||||
|
"env": {}
|
||||||
|
},
|
||||||
|
"claude-haiku-4-5": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"api_key_env": "ANTHROPIC_API_KEY",
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"local/placeholder-orchestrator": {
|
||||||
|
"provider": "openai",
|
||||||
|
"api_key_env": "OPENAI_COMPAT_API_KEY",
|
||||||
|
"env": {
|
||||||
|
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"local/placeholder-worker": {
|
||||||
|
"provider": "openai",
|
||||||
|
"api_key_env": "OPENAI_COMPAT_API_KEY",
|
||||||
|
"env": {
|
||||||
|
"OPENAI_COMPAT_BASE_URL": "http://127.0.0.1:8091/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[project]
|
||||||
|
name = "harbor-buzz-testbed"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Trial provisioning and local Buzz stack tooling for harbor-buzz-orchestra"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"harbor-buzz-orchestra",
|
||||||
|
"coincurve>=20",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.4",
|
||||||
|
"ruff>=0.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
harbor-buzz-orchestra = { path = "..", editable = true }
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 88
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Benchmark schema for harbor-buzz-orchestra runs.
|
||||||
|
--
|
||||||
|
-- Lives in the shared Postgres instance but is OWNED BY THE HARNESS, never by
|
||||||
|
-- Buzz migrations (canonical plan §two-domain rule). Idempotent: safe to apply
|
||||||
|
-- on every testbed bring-up.
|
||||||
|
--
|
||||||
|
-- docker exec -i <postgres> psql -U buzz -d buzz < sql/benchmark_schema.sql
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS benchmark;
|
||||||
|
|
||||||
|
-- One row per provisioned trial; written by BuzzTrialProvisioner.
|
||||||
|
CREATE TABLE IF NOT EXISTS benchmark.trial_manifest (
|
||||||
|
run_id text NOT NULL,
|
||||||
|
trial_id uuid NOT NULL,
|
||||||
|
manifest_hash text NOT NULL,
|
||||||
|
channel_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
handle jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
archived_at timestamptz,
|
||||||
|
PRIMARY KEY (run_id, trial_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Immutable LLM receipts, ingested post-run from the accounting path
|
||||||
|
-- (Databricks AI Gateway inference tables first; LiteLLM shim fallback).
|
||||||
|
-- Authoritative for tokens/cost and orchestrator-vs-worker attribution;
|
||||||
|
-- Harbor AgentContext totals are a reconciliation checksum only.
|
||||||
|
CREATE TABLE IF NOT EXISTS benchmark.llm_receipts (
|
||||||
|
receipt_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id text NOT NULL,
|
||||||
|
trial_id uuid NOT NULL,
|
||||||
|
agent_id text NOT NULL,
|
||||||
|
role text NOT NULL,
|
||||||
|
condition text NOT NULL,
|
||||||
|
endpoint text NOT NULL,
|
||||||
|
model_revision text NOT NULL,
|
||||||
|
request_id text NOT NULL, -- gateway request identity
|
||||||
|
requested_at timestamptz NOT NULL,
|
||||||
|
latency_ms integer,
|
||||||
|
input_tokens bigint NOT NULL DEFAULT 0,
|
||||||
|
cached_input_tokens bigint NOT NULL DEFAULT 0,
|
||||||
|
output_tokens bigint NOT NULL DEFAULT 0,
|
||||||
|
cost_usd numeric(12, 6),
|
||||||
|
source text NOT NULL, -- 'ai_gateway' | 'litellm' | 'client'
|
||||||
|
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (source, request_id),
|
||||||
|
FOREIGN KEY (run_id, trial_id)
|
||||||
|
REFERENCES benchmark.trial_manifest (run_id, trial_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS llm_receipts_trial_idx
|
||||||
|
ON benchmark.llm_receipts (run_id, trial_id, agent_id);
|
||||||
|
|
||||||
|
-- Harness-recorded timing spans (monotonic clocks are authoritative for
|
||||||
|
-- latency). kind examples: 'trial', 'llm_call', 'terminal_exec',
|
||||||
|
-- 'terminal_queue_wait' — queue-wait is recorded separately from execution so
|
||||||
|
-- speed can be reported both as-run and queue-adjusted under the M1
|
||||||
|
-- serialized-broker policy.
|
||||||
|
CREATE TABLE IF NOT EXISTS benchmark.spans (
|
||||||
|
span_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id text NOT NULL,
|
||||||
|
trial_id uuid NOT NULL,
|
||||||
|
agent_id text, -- NULL for trial-level spans
|
||||||
|
kind text NOT NULL,
|
||||||
|
started_at timestamptz NOT NULL,
|
||||||
|
duration_ms bigint NOT NULL CHECK (duration_ms >= 0),
|
||||||
|
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
FOREIGN KEY (run_id, trial_id)
|
||||||
|
REFERENCES benchmark.trial_manifest (run_id, trial_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS spans_trial_kind_idx
|
||||||
|
ON benchmark.spans (run_id, trial_id, kind);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Testbed-side provisioning for harbor-buzz-orchestra trials."""
|
||||||
|
|
||||||
|
from .provisioner import (
|
||||||
|
BuzzTrialProvisioner,
|
||||||
|
ProvisioningError,
|
||||||
|
TestbedConfig,
|
||||||
|
provisioner_from_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BuzzTrialProvisioner",
|
||||||
|
"ProvisioningError",
|
||||||
|
"TestbedConfig",
|
||||||
|
"provisioner_from_dict",
|
||||||
|
]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Thin subprocess wrapper over the ``buzz`` CLI — the production client path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class BuzzCliError(RuntimeError):
|
||||||
|
"""A buzz CLI invocation failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class BuzzCli:
|
||||||
|
"""Run buzz CLI commands as one relay identity (key + NIP-OA auth tag)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
relay_url: str,
|
||||||
|
secret_key: str,
|
||||||
|
auth_tag: str,
|
||||||
|
*,
|
||||||
|
binary: str = "buzz",
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
self._relay_url = relay_url
|
||||||
|
self._secret_key = secret_key
|
||||||
|
self._auth_tag = auth_tag
|
||||||
|
self._binary = binary
|
||||||
|
self._timeout = timeout_seconds
|
||||||
|
|
||||||
|
def run(self, *args: str) -> Any:
|
||||||
|
"""Run a buzz subcommand and return its parsed JSON stdout."""
|
||||||
|
command = [self._binary, *args]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=self._timeout,
|
||||||
|
check=False,
|
||||||
|
env={
|
||||||
|
"BUZZ_RELAY_URL": self._relay_url,
|
||||||
|
"BUZZ_PRIVATE_KEY": self._secret_key,
|
||||||
|
"BUZZ_AUTH_TAG": self._auth_tag,
|
||||||
|
"PATH": _path(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
raise BuzzCliError(f"buzz {args[0]}: {error}") from error
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise BuzzCliError(
|
||||||
|
f"buzz {' '.join(args)} exited {completed.returncode}: "
|
||||||
|
f"{completed.stderr.strip() or completed.stdout.strip()}"
|
||||||
|
)
|
||||||
|
if not completed.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise BuzzCliError(
|
||||||
|
f"buzz {args[0]} returned non-JSON output: {completed.stdout[:200]!r}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def create_private_channel(self, name: str, description: str) -> str:
|
||||||
|
"""Create a private stream channel; return its UUID."""
|
||||||
|
response = self.run(
|
||||||
|
"channels",
|
||||||
|
"create",
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--type",
|
||||||
|
"stream",
|
||||||
|
"--visibility",
|
||||||
|
"private",
|
||||||
|
"--description",
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
channel_id = response.get("channel_id") if isinstance(response, dict) else None
|
||||||
|
if not channel_id:
|
||||||
|
raise BuzzCliError(f"channel create returned no channel_id: {response}")
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
def add_member(self, channel_id: str, pubkey: str) -> None:
|
||||||
|
self.run(
|
||||||
|
"channels",
|
||||||
|
"add-member",
|
||||||
|
"--channel",
|
||||||
|
channel_id,
|
||||||
|
"--pubkey",
|
||||||
|
pubkey,
|
||||||
|
"--role",
|
||||||
|
"member",
|
||||||
|
)
|
||||||
|
|
||||||
|
def archive_channel(self, channel_id: str) -> None:
|
||||||
|
self.run("channels", "archive", "--channel", channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _path() -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
return os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Nostr keygen and NIP-OA owner attestation for trial agents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import coincurve
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NostrKeypair:
|
||||||
|
secret_key: str # hex
|
||||||
|
pubkey: str # x-only hex
|
||||||
|
|
||||||
|
|
||||||
|
def generate_keypair() -> NostrKeypair:
|
||||||
|
"""Generate a fresh secp256k1 keypair in Nostr hex form."""
|
||||||
|
key = coincurve.PrivateKey()
|
||||||
|
return NostrKeypair(
|
||||||
|
secret_key=key.to_hex(),
|
||||||
|
pubkey=key.public_key_xonly.format().hex(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def keypair_from_secret(secret_key: str) -> NostrKeypair:
|
||||||
|
"""Rebuild the keypair for an existing hex secret key."""
|
||||||
|
key = coincurve.PrivateKey(bytes.fromhex(secret_key))
|
||||||
|
return NostrKeypair(
|
||||||
|
secret_key=secret_key,
|
||||||
|
pubkey=key.public_key_xonly.format().hex(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_polymod(values: list[int]) -> int:
|
||||||
|
generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||||
|
checksum = 1
|
||||||
|
for value in values:
|
||||||
|
top = checksum >> 25
|
||||||
|
checksum = (checksum & 0x1FFFFFF) << 5 ^ value
|
||||||
|
for i in range(5):
|
||||||
|
checksum ^= generator[i] if (top >> i) & 1 else 0
|
||||||
|
return checksum
|
||||||
|
|
||||||
|
|
||||||
|
def encode_nsec(secret_key: str) -> str:
|
||||||
|
"""Encode a hex secret key as a NIP-19 bech32 ``nsec1…`` string —
|
||||||
|
the form the desktop GUI's key-import onboarding accepts."""
|
||||||
|
hrp = "nsec"
|
||||||
|
data: list[int] = []
|
||||||
|
accumulator = bits = 0
|
||||||
|
for byte in bytes.fromhex(secret_key):
|
||||||
|
accumulator = accumulator << 8 | byte
|
||||||
|
bits += 8
|
||||||
|
while bits >= 5:
|
||||||
|
bits -= 5
|
||||||
|
data.append(accumulator >> bits & 31)
|
||||||
|
if bits:
|
||||||
|
data.append(accumulator << (5 - bits) & 31)
|
||||||
|
expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||||
|
polymod = _bech32_polymod(expanded + data + [0] * 6) ^ 1
|
||||||
|
checksum = [polymod >> 5 * (5 - i) & 31 for i in range(6)]
|
||||||
|
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in data + checksum)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_auth_tag(
|
||||||
|
owner_secret_key: str, agent_pubkey: str, conditions: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Compute the NIP-OA ``["auth", ...]`` tag authorising an agent key.
|
||||||
|
|
||||||
|
Mirrors crates/buzz-sdk/src/nip_oa.rs:
|
||||||
|
sig = schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions),
|
||||||
|
owner_secret_key). Returns the tag as a JSON string.
|
||||||
|
"""
|
||||||
|
owner = coincurve.PrivateKey(bytes.fromhex(owner_secret_key))
|
||||||
|
preimage = f"nostr:agent-auth:{agent_pubkey}:{conditions}".encode()
|
||||||
|
signature = owner.sign_schnorr(hashlib.sha256(preimage).digest())
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
"auth",
|
||||||
|
owner.public_key_xonly.format().hex(),
|
||||||
|
conditions,
|
||||||
|
signature.hex(),
|
||||||
|
],
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"""Channel-per-trial Buzz provisioning against a local benchmark stack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from harbor_buzz_orchestra.manifest import ExperimentManifest
|
||||||
|
from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
|
||||||
|
|
||||||
|
from .buzz_cli import BuzzCli
|
||||||
|
from .keys import compute_auth_tag, generate_keypair, keypair_from_secret
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisioningError(RuntimeError):
|
||||||
|
"""Trial provisioning failed or was invoked inconsistently."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TestbedConfig:
|
||||||
|
"""Connection settings for one local benchmark stack."""
|
||||||
|
|
||||||
|
__test__ = False # not a pytest class, despite the name
|
||||||
|
relay_http_url: str # e.g. http://localhost:3000 — CLI + healthcheck
|
||||||
|
relay_ws_url: str # as reachable FROM the agents' runtime
|
||||||
|
owner_secret_key: str # relay owner key; signs NIP-OA attestations
|
||||||
|
postgres_dsn: str # benchmark schema lives here
|
||||||
|
llm_api_keys: dict[str, str] = dataclasses.field(default_factory=dict)
|
||||||
|
# endpoint -> API key. v1: per-endpoint resolution; true per-agent
|
||||||
|
# Databricks attribution is pending the AI Gateway field verification.
|
||||||
|
# Pinned user identity (hex secret key). When set, every trial's user —
|
||||||
|
# the human analogue that owns the channel and posts the task — is this
|
||||||
|
# one identity instead of a fresh key per trial, so a GUI logged in as
|
||||||
|
# that user sees every trial channel accumulate. None preserves the
|
||||||
|
# fresh-user-per-trial behaviour.
|
||||||
|
user_secret_key: str | None = None
|
||||||
|
# When False, teardown leaves the trial channel unarchived (and its
|
||||||
|
# archived_at stamp NULL) so finished trials stay visible in a GUI.
|
||||||
|
archive_on_teardown: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def provisioner_from_dict(config: dict[str, object]) -> BuzzTrialProvisioner:
|
||||||
|
"""Harbor CLI factory for a JSON-decoded testbed configuration."""
|
||||||
|
return BuzzTrialProvisioner(TestbedConfig(**config))
|
||||||
|
|
||||||
|
|
||||||
|
class BuzzTrialProvisioner:
|
||||||
|
"""Implements the TrialHandle v1.1 contract against a live Buzz relay.
|
||||||
|
|
||||||
|
Guarantees (contract PLANS/HARBOR_BUZZ_TRIALHANDLE_CONTRACT.md):
|
||||||
|
- create_trial is synchronous and idempotent per (run_id, trial_id);
|
||||||
|
concurrency-safe via a Postgres advisory lock on the trial key.
|
||||||
|
- One private channel per trial; membership is exactly the trial's
|
||||||
|
credentials, so cross-trial reads are blocked by construction.
|
||||||
|
- Fresh keys per trial; never reused.
|
||||||
|
- teardown archives the channel and stamps archived_at; events are
|
||||||
|
never deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: TestbedConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
# -- contract surface ---------------------------------------------------
|
||||||
|
|
||||||
|
def create_trial(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
trial_id: str,
|
||||||
|
manifest: ExperimentManifest,
|
||||||
|
channel_label: str | None = None,
|
||||||
|
) -> TrialHandle:
|
||||||
|
manifest_hash = manifest.sha256
|
||||||
|
with psycopg.connect(self._config.postgres_dsn) as conn:
|
||||||
|
self._lock_trial(conn, run_id, trial_id)
|
||||||
|
existing = self._load_trial(conn, run_id, trial_id)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.manifest_hash != manifest_hash:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"trial ({run_id}, {trial_id}) already provisioned with "
|
||||||
|
f"manifest {existing.manifest_hash}, got {manifest_hash}"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
handle = self._provision(
|
||||||
|
run_id, trial_id, manifest, manifest_hash, channel_label
|
||||||
|
)
|
||||||
|
self._store_trial(conn, handle)
|
||||||
|
conn.commit()
|
||||||
|
return handle
|
||||||
|
|
||||||
|
def teardown(self, handle: TrialHandle) -> None:
|
||||||
|
if not self._config.archive_on_teardown:
|
||||||
|
# GUI/spectator mode: finished trial channels stay visible.
|
||||||
|
return
|
||||||
|
cli = self._cli_for(handle.credentials[0])
|
||||||
|
try:
|
||||||
|
cli.archive_channel(handle.channel_id)
|
||||||
|
except Exception as error:
|
||||||
|
if "archived" not in str(error).lower():
|
||||||
|
raise
|
||||||
|
with psycopg.connect(self._config.postgres_dsn) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE benchmark.trial_manifest"
|
||||||
|
" SET archived_at = COALESCE(archived_at, now())"
|
||||||
|
" WHERE run_id = %s AND trial_id = %s",
|
||||||
|
(handle.run_id, handle.trial_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def healthcheck(self) -> None:
|
||||||
|
url = f"{self._config.relay_http_url.rstrip('/')}/_readiness"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=5) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"relay readiness returned {response.status}"
|
||||||
|
)
|
||||||
|
except (urllib.error.URLError, OSError) as error:
|
||||||
|
raise ProvisioningError(f"relay unreachable at {url}: {error}") from error
|
||||||
|
try:
|
||||||
|
with psycopg.connect(self._config.postgres_dsn, connect_timeout=5) as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
except psycopg.Error as error:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"benchmark postgres unreachable: {error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
# -- internals ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _provision(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
trial_id: str,
|
||||||
|
manifest: ExperimentManifest,
|
||||||
|
manifest_hash: str,
|
||||||
|
channel_label: str | None,
|
||||||
|
) -> TrialHandle:
|
||||||
|
credentials = self._mint_credentials(manifest)
|
||||||
|
user = self._mint_user()
|
||||||
|
# The user identity creates the channel and invites the agents —
|
||||||
|
# mirroring production Buzz, where a human owns the channel their
|
||||||
|
# agents work in.
|
||||||
|
cli = self._cli_for(user)
|
||||||
|
name = (
|
||||||
|
f"{channel_label}-{trial_id[:8]}"
|
||||||
|
if channel_label
|
||||||
|
else f"trial-{trial_id[:8]}-{manifest_hash[:8]}"
|
||||||
|
)
|
||||||
|
channel_id = cli.create_private_channel(
|
||||||
|
name=name,
|
||||||
|
description=f"run={run_id} trial={trial_id} manifest={manifest_hash}",
|
||||||
|
)
|
||||||
|
for credential in credentials:
|
||||||
|
cli.add_member(channel_id, credential.nostr_pubkey)
|
||||||
|
return TrialHandle(
|
||||||
|
run_id=run_id,
|
||||||
|
trial_id=trial_id,
|
||||||
|
manifest_hash=manifest_hash,
|
||||||
|
relay_ws_url=self._config.relay_ws_url,
|
||||||
|
channel_id=channel_id,
|
||||||
|
credentials=credentials,
|
||||||
|
user=user,
|
||||||
|
user_relay_url=self._config.relay_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mint_credentials(
|
||||||
|
self, manifest: ExperimentManifest
|
||||||
|
) -> tuple[AgentCredential, ...]:
|
||||||
|
roster = sorted(manifest.roster, key=lambda e: e.kind != "orchestrator")
|
||||||
|
credentials: list[AgentCredential] = []
|
||||||
|
for entry in roster:
|
||||||
|
api_key = self._config.llm_api_keys.get(entry.endpoint)
|
||||||
|
if api_key is None:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"no LLM API key configured for endpoint {entry.endpoint!r}"
|
||||||
|
)
|
||||||
|
for index in range(1, entry.count + 1):
|
||||||
|
keypair = generate_keypair()
|
||||||
|
credentials.append(
|
||||||
|
AgentCredential(
|
||||||
|
agent_id=f"{entry.id}-{index}",
|
||||||
|
role=entry.kind,
|
||||||
|
nostr_secret_key=keypair.secret_key,
|
||||||
|
nostr_pubkey=keypair.pubkey,
|
||||||
|
nostr_auth_tag=compute_auth_tag(
|
||||||
|
self._config.owner_secret_key, keypair.pubkey
|
||||||
|
),
|
||||||
|
llm_endpoint=entry.endpoint,
|
||||||
|
llm_api_key=api_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(credentials)
|
||||||
|
|
||||||
|
def _mint_user(self) -> AgentCredential:
|
||||||
|
"""Mint the trial's user identity — the human analogue, not an agent.
|
||||||
|
|
||||||
|
With a pinned ``user_secret_key`` the same identity fronts every
|
||||||
|
trial, like one human running many teams; otherwise each trial gets
|
||||||
|
a fresh user key.
|
||||||
|
"""
|
||||||
|
keypair = (
|
||||||
|
keypair_from_secret(self._config.user_secret_key)
|
||||||
|
if self._config.user_secret_key
|
||||||
|
else generate_keypair()
|
||||||
|
)
|
||||||
|
return AgentCredential(
|
||||||
|
agent_id="user",
|
||||||
|
role="user",
|
||||||
|
nostr_secret_key=keypair.secret_key,
|
||||||
|
nostr_pubkey=keypair.pubkey,
|
||||||
|
nostr_auth_tag=compute_auth_tag(
|
||||||
|
self._config.owner_secret_key, keypair.pubkey
|
||||||
|
),
|
||||||
|
llm_endpoint="",
|
||||||
|
llm_api_key="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cli_for(self, credential: AgentCredential) -> BuzzCli:
|
||||||
|
return BuzzCli(
|
||||||
|
relay_url=self._config.relay_http_url,
|
||||||
|
secret_key=credential.nostr_secret_key,
|
||||||
|
auth_tag=credential.nostr_auth_tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _lock_trial(conn: psycopg.Connection, run_id: str, trial_id: str) -> None:
|
||||||
|
digest = hashlib.sha256(f"{run_id}\x00{trial_id}".encode()).digest()
|
||||||
|
lock_key = int.from_bytes(digest[:8], "big", signed=True)
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(%s)", (lock_key,))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_trial(
|
||||||
|
conn: psycopg.Connection, run_id: str, trial_id: str
|
||||||
|
) -> TrialHandle | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT handle FROM benchmark.trial_manifest"
|
||||||
|
" WHERE run_id = %s AND trial_id = %s",
|
||||||
|
(run_id, trial_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
stored = row[0]
|
||||||
|
return TrialHandle(
|
||||||
|
run_id=stored["run_id"],
|
||||||
|
trial_id=stored["trial_id"],
|
||||||
|
manifest_hash=stored["manifest_hash"],
|
||||||
|
relay_ws_url=stored["relay_ws_url"],
|
||||||
|
channel_id=stored["channel_id"],
|
||||||
|
credentials=tuple(
|
||||||
|
AgentCredential(**credential) for credential in stored["credentials"]
|
||||||
|
),
|
||||||
|
user=AgentCredential(**stored["user"]),
|
||||||
|
user_relay_url=stored.get("user_relay_url", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _store_trial(conn: psycopg.Connection, handle: TrialHandle) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO benchmark.trial_manifest"
|
||||||
|
" (run_id, trial_id, manifest_hash, channel_id, handle)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(
|
||||||
|
handle.run_id,
|
||||||
|
handle.trial_id,
|
||||||
|
handle.manifest_hash,
|
||||||
|
handle.channel_id,
|
||||||
|
json.dumps(dataclasses.asdict(handle)),
|
||||||
|
),
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user