diff --git a/.env.example b/.env.example index 5a02f8d37a..a8742bdf51 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ 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) # ----------------------------------------------------------------------------- @@ -65,30 +69,37 @@ RELAY_URL=ws://localhost:3000 # BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300 # BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600 -# Corporate identity verification (disabled by default). When enabled, the relay +# Relay-verified identity (disabled by default). When enabled, the relay # requires authenticated requests to present a valid corporate JWT, then binds # the configured uid claim to the Nostr pubkey proven by NIP-42/NIP-98. The JWT # may be injected by a trusted proxy or attached by a first-party client; the -# relay treats both as the same header. Client token forwarding and admin -# revocation/rotation workflows are separate follow-up features. +# relay treats both as the same header. Clients must forward the configured +# token header on every authenticated HTTP request and session handshake. # # Operational notes for the initial implementation: # - When a trusted proxy injects this header, it MUST overwrite any inbound # client-supplied value before forwarding to the relay. -# - A uid/pubkey conflict is a hard lockout until an operator resolves the -# binding out of band; admin revocation/rotation endpoints are not in this PR. +# - Revocation and rotation are explicit database lifecycle operations; +# ordinary authentication never silently replaces a key. # - JWKS outages fail closed for human JWT authentication. Delegated agent # admission can still work when the owner binding is already present. -# - DISPLAY_CLAIM is stored in identity_bindings.display_name. Using email here -# stores corporate PII and PR2 may surface it in clients. +# - DISPLAY_CLAIM is private binding metadata. It is never projected publicly +# unless PUBLIC_DISPLAY_CLAIM is separately configured. # BUZZ_REQUIRE_CORPORATE_IDENTITY=false # BUZZ_CORPORATE_IDENTITY_JWT_HEADER=x-forwarded-identity-token # BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION=true +# When a request carries both a JWT and a verified NIP-OA owner declaration, +# choose whether the JWT identifies the signer or the owner binding delegates +# access. Defaults to direct; deployments that inject an owner's JWT into agent +# requests can explicitly select delegated. +# BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE=direct # BUZZ_CORPORATE_IDENTITY_JWKS_URI=https://idp.example/.well-known/jwks.json # BUZZ_CORPORATE_IDENTITY_ISSUER=https://idp.example # BUZZ_CORPORATE_IDENTITY_AUDIENCE=buzz-relay # BUZZ_CORPORATE_IDENTITY_UID_CLAIM=sub # BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM=email +# Optional, public NIP-85 label. Unset by default to keep identity claims private. +# BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM=display_name # BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM=buzz_npub # ----------------------------------------------------------------------------- @@ -104,6 +115,19 @@ RELAY_URL=ws://localhost:3000 # 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 # ----------------------------------------------------------------------------- @@ -111,11 +135,10 @@ RELAY_URL=ws://localhost:3000 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 -# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. -# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. -# BUZZ_REQUIRE_MEDIA_GET_AUTH=false -# Legacy alias accepted by the relay while rollout docs catch up: -# BUZZ_REQUIRE_MEDIA_READ_AUTH=false +# 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) @@ -131,7 +154,10 @@ RELAY_URL=ws://localhost:3000 # ----------------------------------------------------------------------------- # Logging / Tracing # ----------------------------------------------------------------------------- -RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug +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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..f36b1615d0 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 25f0bc5ed8..e383313452 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,31 +1,24 @@ --- -name: 🐛 Bug Report -about: Thank you for taking the time, please report a reproducible bug -title: "[Bug] " +name: Bug report +about: Report a reproducible bug in Buzz labels: bug -assignees: add codeowner's @name here - --- **Describe the bug** -*A clear and concise description of what the bug is.* +A clear and concise description of what the bug is. -**To Reproduce:** -*Steps to reproduce the behavior:* +**Steps to reproduce** 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +2. Click on '...' +3. See error -**Expected behavior:** -*A clear and concise description of what you expected to happen.* +**Expected behavior** +What did you expect to happen? -**Supporting Material** -*If applicable, add screenshots, output log and/or other documentation to help explain your problem.* +**Version and platform** +Find your version at the bottom of the Settings sidebar. Write "unknown" if you can't determine it. +- Buzz version: +- OS: -**Environment (please complete the following information):** - - OS: [ex: iOS] - - Version - -**Additional context** -Add any other context that you feel is relevant about the problem here. +**Logs / additional context** +Paste any relevant logs, error messages, or screenshots here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0ba9db2532..0086358db1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1 @@ -contact_links: - - name: ❓ Questions and Help 🤔 - url: https://discord.gg/block-opensource (/add your discord channel if applicable) - about: This issue tracker is not for support questions. Please refer to the community for more help. +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000000..ba5132f0e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -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". diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..238878c749 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## Summary + + +### Related issue + + +### Testing + diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2c..3db5c6baaa 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # 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/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,12 +35,17 @@ permissions: 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 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { 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 @@ -102,21 +132,32 @@ jobs: 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" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + 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 $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ - --silent + -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 diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml index 31efe933c5..6024f00575 100644 --- a/.github/workflows/benchmark-harbor.yml +++ b/.github/workflows/benchmark-harbor.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7f77f6bf..761095f35a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 @@ -48,29 +48,44 @@ jobs: - 'scripts/run-tests.sh' - 'justfile' desktop: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' web: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'web/**' - 'pnpm-lock.yaml' mobile: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'mobile/**' - 'scripts/mobile-release.sh' + - 'scripts/mobile-worktree-overrides.sh' + - 'scripts/mobile-worktree-clean.sh' - 'scripts/publish-mobile-release-candidate.sh' - 'scripts/release-rulesets.sh' - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' + - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh scripts/test-mobile-release-candidate-publisher.sh + - name: Mobile worktree identity contract + run: scripts/test-mobile-worktree-overrides.sh + - name: File size ratchet unit tests + run: node --test scripts/check-file-sizes-core.test.mjs rust-lint: name: Rust Lint @@ -81,7 +96,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -102,7 +117,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -124,7 +139,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -208,7 +225,7 @@ jobs: desktop-smoke-e2e: name: Desktop Smoke E2E (${{ matrix.shard }}) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' strategy: @@ -218,7 +235,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -301,7 +318,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 # Reuse the relay binaries and backend test archive when none of their # inputs changed (desktop-only PRs hit this every time). The key covers @@ -335,6 +352,7 @@ jobs: cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ --cargo-profile ci \ + -p buzz-db \ -p buzz-relay \ -p buzz-test-client \ --lib \ @@ -373,7 +391,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Start integration services run: | @@ -562,7 +580,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -666,11 +684,36 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite claim security tests + - name: Invite security tests run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(claim_)' \ + -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Relay-verified identity lifecycle tests + run: | + docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \ + psql -U buzz -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE buzz_identity_tests" + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(/migration::tests::nip_fi_(authorization_tests|direct_final_tests)::/)' \ + --no-tests=fail \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests + - name: Workspace profile (kind:9033) gate tests + # Call-site integration for the 9033 authorization gate: open relay + # rosterless/steward transitions and the closed-relay admin/owner rule, + # against real Postgres. #[ignore]d in the default suite, selected + # explicitly here — see handlers::relay_admin::tests. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz @@ -686,6 +729,17 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: NIP-MP coordinate deletion guard + # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: + # a stale tombstone (created_at earlier than the live head) spares that + # head, and an equal-timestamp tombstone deletes it. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -703,7 +757,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -721,12 +775,25 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -744,7 +811,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -777,7 +846,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Compute Hermit cache key id: hermit-bin-hash @@ -816,6 +887,8 @@ jobs: with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: File size ratchet + run: node mobile/scripts/check-file-sizes.mjs - name: Format check run: cd mobile && dart format --output=none --set-exit-if-changed . - name: Analyze @@ -834,7 +907,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Dependency policy run: cargo-deny check @@ -846,7 +919,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Check for dead API token references in client code run: | # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. @@ -875,7 +948,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -912,7 +985,7 @@ jobs: env: TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows # runner — hermit, used by the Linux jobs, does not provide MSVC. The # toolchain (1.95.0 + clippy via profile = default) comes from the @@ -976,7 +1049,7 @@ jobs: git log -1 --format=%s | grep -qx smoke echo "Host bash resolved and functional; git commit round-trip passed" - name: Check (Tauri crate) - run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Test (Tauri crate) @@ -993,7 +1066,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -1007,6 +1080,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml new file mode 100644 index 0000000000..cf9c8e7827 --- /dev/null +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -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; }' diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 0000000000..61ccc800af --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -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" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 31080652ea..564cd74e9d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ name: Docker image -# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz. +# 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 @@ -15,8 +17,10 @@ name: Docker image # # Triggers: # - push to main → :main + :sha-<7> +# + :debug-main + :debug-sha-<7> # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} -# (+ :latest for stable, NOT for prereleases) +# + 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 # @@ -95,13 +99,9 @@ jobs: runner: ubuntu-24.04-arm arch: arm64 - outputs: - # Used downstream by `merge` to stitch the manifest. - version: ${{ steps.meta.outputs.version }} - steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -163,12 +163,13 @@ jobs: org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform org.opencontainers.image.licenses=Apache-2.0 - - name: Build and push by digest - id: build + - 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 @@ -180,25 +181,49 @@ jobs: 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 + - 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: - DIGEST: ${{ steps.build.outputs.digest }} + RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }} + DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }} run: | - mkdir -p /tmp/digests - touch "/tmp/digests/${DIGEST#sha256:}" + mkdir -p /tmp/digests-release /tmp/digests-debug + touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}" + touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}" - - name: Upload digest + - name: Upload release digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: digests-${{ matrix.arch }} - path: /tmp/digests/* + 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 multi-arch manifest + name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: build @@ -208,13 +233,21 @@ jobs: 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-* + pattern: digests-${{ matrix.variant }}-* merge-multiple: true - name: Set up Docker Buildx @@ -237,9 +270,12 @@ jobs: # 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=sha-,format=short,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 }} @@ -284,11 +320,12 @@ jobs: - 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}\`" + echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" echo @@ -322,7 +359,7 @@ jobs: arch: arm64 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e3d443d9f3..7118d16708 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -59,7 +59,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 @@ -119,7 +119,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -166,7 +166,7 @@ jobs: packages: write # push the chart to GHCR steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + 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. diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e400..d8b10032b2 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -7,8 +7,8 @@ name: Linux Canary # 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). -# - mold linker added (rui314/setup-mold) to reduce link time, matching -# the Linux Rust CI jobs in ci.yml. +# - 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: @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 timeout-minutes: 60 permissions: contents: read @@ -83,18 +83,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to linux-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: linux-canary-release - - name: Install appimagetool run: | case "$(uname -m)" in @@ -154,6 +142,38 @@ jobs: 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' @@ -166,11 +186,11 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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 --config src-tauri/tauri.canary.conf.json + 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" @@ -190,6 +210,24 @@ jobs: 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: diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml new file mode 100644 index 0000000000..35b05313c9 --- /dev/null +++ b/.github/workflows/macos-intel-canary.yml @@ -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 }} diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml new file mode 100644 index 0000000000..4780083ba4 --- /dev/null +++ b/.github/workflows/mesh-lifecycle.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3e20c393d..9da067b74e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -46,7 +36,7 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -107,14 +64,14 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - 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 v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -134,7 +91,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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. @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # 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" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - 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) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -323,14 +278,14 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-apple-darwin steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - 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 v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -353,7 +308,7 @@ jobs: - name: Build sidecars run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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 @@ -443,6 +398,11 @@ jobs: 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" @@ -453,34 +413,26 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - 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:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -498,7 +450,7 @@ jobs: env: DEBIAN_FRONTEND: noninteractive run: | - # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or + # 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. @@ -543,7 +495,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends gh - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -611,7 +563,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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 @@ -621,7 +573,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json - name: Build Linux Tauri app - run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json + 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 @@ -629,7 +581,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - name: Fix AppImage (remove infra libs, symlink system GStreamer) + - 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 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - 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 @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -727,7 +666,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - 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 @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + 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 @@ -863,14 +795,33 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - 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 v "$VERSION" + 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: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + 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 - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + 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 diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028a..5957f4785d 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -34,16 +34,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to macos-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: macos-canary-release - - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -78,6 +68,38 @@ jobs: 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' @@ -93,7 +115,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + 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. @@ -210,6 +232,24 @@ jobs: 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: diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml new file mode 100644 index 0000000000..5d5e12ae0c --- /dev/null +++ b/.github/workflows/sprig-image.yml @@ -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: --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" diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index b2dab3583f..5e50808b3b 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -42,7 +42,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -116,7 +116,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -154,7 +154,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..7093efd2dc 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -46,24 +46,9 @@ jobs: shell: bash run: rustup target add "$TARGET" - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to windows-canary-release so canary - # runs warm each other without colliding with CI's debug-profile key - # (CI windows job does clippy/check, not --release). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: windows-canary-release - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - # Disable setup-node's built-in cache: we manage the pnpm store cache - # explicitly below (restore before install, save after) to mirror the - # pattern used by ci.yml and to keep caching logic consistent across - # all three canary workflows. package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -108,6 +93,40 @@ jobs: 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: | @@ -152,6 +171,25 @@ jobs: 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: diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..f26e74136c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /dist/ /admin-web/dist/ +# Python cache +__pycache__/ +*.pyc + # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..843ac0dd7d --- /dev/null +++ b/.release/desktop-candidate.json @@ -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 +} diff --git a/AGENTS.md b/AGENTS.md index 3edfb34f60..2d3939bbb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,14 +13,14 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, | Repo | Purpose | |------|---------| | [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness | -| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix | +| [squareup/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) - ├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases) + ├─► 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) @@ -100,14 +100,17 @@ Run `just test` for integration tests if you touched `buzz-relay`, formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format). Auto-fixable issues are fixed and re-staged; unfixable lint issues block the -commit. **Pre-push hooks** run clippy (workspace + Tauri) and fast unit tests -in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with -pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix all formatting -in one shot. Run `just ci` for the full local gate. Run `just hooks` to +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 @@ -143,6 +146,10 @@ 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. @@ -341,9 +348,19 @@ 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 `pnpm run build` before re-running tests -after code changes. +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 @@ -491,6 +508,7 @@ reconnects preserve pending avatar verification work): - `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()`.** @@ -553,6 +571,15 @@ To run the app locally (starts Docker, relay, iOS simulator automatically): 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..892082d96c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa | 46001–46012 | KIND_WORKFLOW_* | Workflow execution events | | 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat | -`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). +`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`. @@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from **Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt. -**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap. +**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:** ``` @@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h | Pattern | Type | TTL | Purpose | |---------|------|-----|---------| | `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) | -| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) | +| `buzz: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) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42a6c2203..7ca9525074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,402 @@ # Changelog +## v0.5.8 + +### Desktop and shared changes + +- feat(desktop): unify add agent flows ([#5015](https://github.com/block/buzz/pull/5015)) ([`02f640bc4559c48ac0c2ec595ef34dd2c294b0db`](https://github.com/block/buzz/commit/02f640bc4559c48ac0c2ec595ef34dd2c294b0db)) +- fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary ([#5248](https://github.com/block/buzz/pull/5248)) ([`c7b663680a29a837dbd2fdde810f239f3d303025`](https://github.com/block/buzz/commit/c7b663680a29a837dbd2fdde810f239f3d303025)) + +### Other repository changes + +- Revert "fix(acp): reject unattended permission requests" ([#5323](https://github.com/block/buzz/pull/5323)) ([`6a17d035f79ad582ca3f4f3cdc38d376f2c4087f`](https://github.com/block/buzz/commit/6a17d035f79ad582ca3f4f3cdc38d376f2c4087f)) +- infra: bind development services to loopback ([#4871](https://github.com/block/buzz/pull/4871)) ([`65834d68d0d3441c4e628540d6d5c8b0a2e757c9`](https://github.com/block/buzz/commit/65834d68d0d3441c4e628540d6d5c8b0a2e757c9)) + +[Compare desktop-v0.5.7...desktop-v0.5.8](https://github.com/block/buzz/compare/desktop-v0.5.7...desktop-v0.5.8) + +## v0.5.7 + +### Desktop and shared changes + +- fix(desktop): isolate relay admission tests ([#5221](https://github.com/block/buzz/pull/5221)) ([`74b913cff8512c015dc6f1a7473b253fa803f954`](https://github.com/block/buzz/commit/74b913cff8512c015dc6f1a7473b253fa803f954)) +- fix(desktop): externalize boot + ', + )}`; + + assert.equal(catalogAvatarUrl(scripted), scripted); +}); + +test("foreign allowlist behavior imports as owner-only", () => { + const personas = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "allowlist", + respondTo: "allowlist", + }), + ]), + [], + BOB, + ); + + assert.equal(personas[0].respondTo, "owner-only"); + assert.deepEqual(personas[0].respondToAllowlist, []); +}); + +test("a pending local share does not appear before relay confirmation", () => { + const localPersona = { + id: "local-reviewer", + displayName: "Local Reviewer", + avatarUrl: null, + systemPrompt: "Review local changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; + + const personas = catalogPersonasFromPublications([], [localPersona], ALICE); + assert.deepEqual(personas, []); +}); + +function localPersona(overrides = {}) { + return { + id: "local-1", + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: false, + sourceTeam: null, + catalogSource: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + ...overrides, + }; +} + +// The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so +// matching by id finds nothing and the catalog offers "Add" again. Only the +// stored catalogSource coordinate links the copy back to the publication. +test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => { + const publisherAvatar = "https://relay.example/publisher.png"; + const publications = catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "alice-reviewer", + avatarUrl: publisherAvatar, + }), + ]); + const copy = localPersona({ + id: "a-fresh-uuid", + displayName: "Locally Renamed Reviewer", + avatarUrl: "https://relay.example/local-copy.png", + systemPrompt: "Locally edited instructions.", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications(publications, [copy], BOB); + + assert.equal(personas.length, 1); + assert.equal( + personas[0].id, + "a-fresh-uuid", + "the projection must retain the existing local copy's linkage id", + ); + assert.equal( + personas[0].isActive, + true, + "an added foreign entry must read as already selected", + ); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].avatarUrl, publisherAvatar); + assert.equal(personas[0].systemPrompt, "Review changes."); +}); + +test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + // A same-named local persona with no provenance is a different agent. + const unrelated = localPersona({ id: "unrelated" }); + + const personas = catalogPersonasFromPublications( + publications, + [unrelated], + BOB, + ); + + assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +// Provenance is per-owner: the same d-tag under a different publisher is a +// different agent, so a copy of Alice's must not mask Bob's entry. +test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "bob-reviewer", owner: BOB }), + ]); + const copyOfAlices = localPersona({ + id: "copy-of-alices", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications( + publications, + [copyOfAlices], + ALICE, + ); + + assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +test("test_own_publication_still_resolves_by_local_id", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const own = localPersona({ id: "reviewer", shared: true }); + + const personas = catalogPersonasFromPublications(publications, [own], ALICE); + + assert.equal(personas[0].id, "reviewer"); + assert.equal(personas[0].catalogSource.isOwn, true); +}); + +function pageOfEvents(count, startId, createdAt) { + return Array.from({ length: count }, (_, index) => + personaEvent({ + createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt, + id: `event-${startId + index}`, + sourcePersonaId: `persona-${startId + index}`, + }), + ); +} + +function stubPagedRelay(pages) { + const filters = []; + mock.method(relayClient, "fetchEvents", (filter) => { + filters.push(filter); + return Promise.resolve(pages[filters.length - 1] ?? []); + }); + return filters; +} + +// A single limit-capped fetch drops every entry past the relay's clamp, making +// those agents undiscoverable. The walk must keep going while pages come back +// full, and must carry an `until` cursor derived from the oldest event seen. +test("test_full_page_is_followed_by_a_cursored_request_for_older_events", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([ + pageOfEvents(500, 0, (index) => 10_000 - index), + pageOfEvents(3, 500, 9_000), + ]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 2, "a full page must be followed by another"); + assert.equal(filters[0].until, undefined, "the first page has no cursor"); + assert.equal( + filters[1].until, + 10_000 - 499, + "the cursor must be the oldest created_at from the previous page", + ); + assert.equal( + publications.length, + 503, + "entries past the first page must still be discoverable", + ); +}); + +test("test_short_first_page_does_not_issue_a_second_request", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 1); + assert.equal(publications.length, 2); +}); + +// `until` is inclusive on the relay, so consecutive pages overlap on the +// boundary timestamp. Without id dedupe the repeats would be counted twice. +test("test_overlapping_pages_are_deduped_by_event_id", async (t) => { + t.after(() => mock.restoreAll()); + const firstPage = pageOfEvents(500, 0, (index) => 10_000 - index); + const secondPage = [ + // The boundary event repeats because `until` includes its timestamp. + firstPage[firstPage.length - 1], + ...pageOfEvents(2, 500, 9_000), + ]; + stubPagedRelay([firstPage, secondPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(publications.length, 502, "the repeated event must count once"); +}); + +// The stop-on-no-progress guard: a full page whose events all share one +// created_at cannot advance the cursor, so paging must terminate instead of +// re-requesting the same page forever. +test("test_full_page_of_tied_timestamps_terminates_the_walk", async (t) => { + t.after(() => mock.restoreAll()); + const tiedPage = pageOfEvents(500, 0, 10_000); + const filters = stubPagedRelay([tiedPage, tiedPage, tiedPage, tiedPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal( + filters.length, + 2, + "the walk must stop once a page contributes nothing new", + ); + assert.equal(publications.length, 500); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 0000000000..a588843b1e --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,388 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { + AgentPersona, + CatalogSourceCoordinate, + RelayEvent, + RespondToMode, +} from "@/shared/api/types"; +import { KIND_PERSONA } from "@/shared/constants/kinds"; + +export type CatalogPersonaShareLevel = "not-shared" | "none"; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; + namePool: string[]; + respondTo: RespondToMode | null; + parallelism: number | null; +}; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + createdAt: number; + agent: CatalogAgentProjection; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: CatalogSourceCoordinate & { + /** The publication event this projection was built from. */ + eventId: string; + /** Whether the current identity published it. */ + isOwn: boolean; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +export function personaEventIsShared(event: RelayEvent): boolean { + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + return ( + sharedTags.length === 1 && + sharedTags[0]?.length === 2 && + sharedTags[0]?.[1] === "true" + ); +} + +function isSafeHttpUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 2_048 || + /[\s()]/u.test(value) + ) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +/** + * Emoji avatars are the one `data:` avatar a catalog entry keeps. + * + * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in + * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on + * any member's machine — unlike a bundled runtime-default avatar, whose local + * asset path means nothing to another install. The accepted shape is exactly + * that prefix: the trailing comma is what rejects `;base64` payloads, and + * every other `data:` MIME stays rejected. Catalog avatars render through + * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never + * executes, so bounding the length is the remaining concern — 8 KiB is an + * order of magnitude above the ~700 characters an emoji avatar encodes to. + */ +const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; +const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; + +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + +function isInlineSvgAvatar(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith(INLINE_SVG_AVATAR_PREFIX) && + value.length <= MAX_INLINE_SVG_AVATAR_LENGTH + ); +} + +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if ( + !isObject(parsed) || + typeof parsed.display_name !== "string" || + parsed.display_name.trim().length === 0 + ) { + return null; + } + + const avatarUrl = + isSafeHttpUrl(parsed.avatar_url) || + isInlineSvgAvatar(parsed.avatar_url) || + isInlineRasterAvatar(parsed.avatar_url) + ? parsed.avatar_url + : null; + const namePool = Array.isArray(parsed.name_pool) + ? parsed.name_pool.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : []; + const respondTo = + parsed.respond_to === "allowlist" + ? "owner-only" + : parsed.respond_to === "owner-only" || parsed.respond_to === "anyone" + ? parsed.respond_to + : null; + const parallelism = + typeof parsed.parallelism === "number" && + Number.isInteger(parsed.parallelism) && + parsed.parallelism >= 1 && + parsed.parallelism <= 32 + ? parsed.parallelism + : null; + + return { + displayName: parsed.display_name, + avatarUrl, + systemPrompt: + typeof parsed.system_prompt === "string" ? parsed.system_prompt : "", + runtime: optionalString(parsed.runtime), + model: optionalString(parsed.model), + provider: optionalString(parsed.provider), + namePool, + respondTo, + parallelism, + }; +} + +/** + * Collapse relay results to the canonical NIP-33 head for each persona + * coordinate, then keep only exact `["shared", "true"]` heads. + * + * The relay normally returns one replaceable head. The client-side collapse is + * defense in depth for older relays and fixtures, and deliberately claims the + * coordinate before parsing so an invalid or unshared newest head cannot + * resurrect an older shared definition. + */ +export function catalogPublicationsFromEvents( + events: readonly RelayEvent[], +): PersonaCatalogPublication[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ); + const seenCoordinates = new Set(); + const publications: PersonaCatalogPublication[] = []; + + for (const event of sorted) { + if (event.kind !== KIND_PERSONA) continue; + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + if (!personaEventIsShared(event)) continue; + const agent = parsePersonaContent(event); + if (!agent) continue; + publications.push({ + eventId: event.id, + ownerPubkey, + sourcePersonaId, + createdAt: event.created_at, + agent, + }); + } + + return publications; +} + +/** + * Events per catalog page. + * + * Kept well under the relay's 1,000-row `query_events` clamp so a page that + * comes back full is a reliable "there may be more" signal rather than a + * silently truncated result. + */ +const CATALOG_PAGE_SIZE = 500; + +/** + * Hard bound on pages walked, so a relay that keeps returning full pages can + * never spin this forever. + */ +const MAX_CATALOG_PAGES = 40; + +/** + * Read every shared persona event, page by page. + * + * A single `limit`-capped fetch silently truncates once a community publishes + * more agents than the relay's clamp, and the entries that fall off are simply + * undiscoverable. Paging walks backwards through `created_at` using the only + * cursor a WS `REQ` filter carries — `until` — which the relay treats as + * *inclusive*, so consecutive pages overlap on tied timestamps. Two things + * follow, and both are load-bearing: + * + * - dedupe by event id, because the boundary events repeat; and + * - stop when a page contributes nothing new, because a page whose events all + * share one `created_at` would otherwise be requested forever. + */ +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const byId = new Map(); + let until: number | undefined; + + for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA], + limit: CATALOG_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of events) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + + // A short page is the end of the catalog; a page of only-repeats means the + // cursor cannot advance past a run of tied timestamps. + if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { + break; + } + until = oldestCreatedAt; + } + + return catalogPublicationsFromEvents([...byId.values()]); +} + +function publicationToPersona( + publication: PersonaCatalogPublication, + localPersona: AgentPersona | undefined, + isOwn: boolean, +): CatalogPersona { + const timestamp = new Date(publication.createdAt * 1_000).toISOString(); + // The publication remains authoritative for catalog presentation. An added + // local copy contributes only the linkage id and selected state; merging the + // whole copy would leak local edits (notably its avatar) into the publisher's + // catalog entry. + const basePersona: AgentPersona = { + id: + localPersona?.id ?? + `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + displayName: publication.agent.displayName, + avatarUrl: publication.agent.avatarUrl, + systemPrompt: publication.agent.systemPrompt, + runtime: publication.agent.runtime, + model: publication.agent.model, + provider: publication.agent.provider, + namePool: publication.agent.namePool, + isBuiltIn: false, + isActive: localPersona?.isActive ?? false, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: publication.agent.respondTo, + respondToAllowlist: [], + parallelism: publication.agent.parallelism, + createdAt: timestamp, + updatedAt: timestamp, + }; + + return { + ...basePersona, + // Catalog membership is relay-confirmed by the shared event itself. Do not + // let a local pending toggle override this projection. + shared: true, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn, + personaId: publication.sourcePersonaId, + }, + }; +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + const personas: CatalogPersona[] = []; + + for (const publication of publications) { + const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; + personas.push( + publicationToPersona( + publication, + findLocalPersonaForCatalogEntry(localPersonas, { + ownerPubkey: publication.ownerPubkey, + personaId: publication.sourcePersonaId, + isOwn, + }), + isOwn, + ), + ); + } + + return personas.sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); +} + +/** + * The local persona backing a catalog entry, if the user already has it. + * + * An own publication is found by id — its `d`-tag *is* the local persona id. A + * copy of another owner's entry carries a fresh local id instead, so the only + * link back is the `catalogSource` coordinate stored on the copy. Matching on + * that coordinate is what stops the catalog from offering "Add" for an entry + * the user already added, which would mint a second copy. + */ +export function findLocalPersonaForCatalogEntry( + localPersonas: readonly AgentPersona[], + source: CatalogSourceCoordinate & { isOwn: boolean }, +): AgentPersona | undefined { + if (source.isOwn) { + return localPersonas.find((persona) => persona.id === source.personaId); + } + return localPersonas.find( + (persona) => + persona.catalogSource?.ownerPubkey === source.ownerPubkey && + persona.catalogSource?.personaId === source.personaId, + ); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} diff --git a/desktop/src/features/agents/lib/personaEditCaches.ts b/desktop/src/features/agents/lib/personaEditCaches.ts new file mode 100644 index 0000000000..c5071d1246 --- /dev/null +++ b/desktop/src/features/agents/lib/personaEditCaches.ts @@ -0,0 +1,42 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { evictUsersBatchEntries } from "@/features/profile/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Refresh every cache a saved persona edit can invalidate. + * + * Shared by the plain edit mutation and the publish-on-save edit mutation so + * the two cannot drift on what a saved edit refreshes. + */ +export async function invalidatePersonaEditCaches( + queryClient: QueryClient, + personaId: string, +): Promise { + // Evict per-pubkey users-batch-entry caches for agents linked to this + // persona so the batch invalidation below refetches fresh profiles instead + // of re-reading stale entries (mirrors useUpdateManagedAgentMutation). + const agents = queryClient.getQueryData(["managed-agents"]); + if (agents) { + evictUsersBatchEntries( + queryClient, + agents + .filter((agent) => agent.personaId === personaId) + .map((agent) => agent.pubkey.toLowerCase()), + ); + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["personas"] }), + queryClient.invalidateQueries({ queryKey: ["managed-agents"] }), + // Persona avatar changes re-sync linked agents' relay profiles; + // invalidate cached user-profile and users-batch queries so the UI picks + // up the updated kind:0 picture without waiting for staleTime expiry — + // covers agent cards, message timelines, and member lists. + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "user-profile" || + query.queryKey[0] === "users-batch", + }), + ]); +} diff --git a/desktop/src/features/agents/lib/personaSaveNotice.test.mjs b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs new file mode 100644 index 0000000000..36f3fcdc8a --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaSaveNotice } from "./personaSaveNotice.ts"; + +test("test_plain_save_notice_says_nothing_about_the_catalog", () => { + const notice = personaSaveNotice("Helper", null); + assert.equal(notice, "Updated Helper."); + assert.ok(!/catalog/i.test(notice)); +}); + +test("test_accepted_publish_notice_claims_the_catalog_has_the_edit", () => { + assert.match( + personaSaveNotice("Helper", "published"), + /published it to the community catalog/, + ); +}); + +// The whole point of routing "Save and publish" through the strict command is +// that a queued edit must NOT be reported as published — the relay hasn't taken +// it yet, so the catalog still shows the old definition. +test("test_queued_publish_notice_does_not_claim_the_edit_is_published", () => { + const notice = personaSaveNotice("Helper", "queued"); + assert.match(notice, /queued/); + assert.ok( + !/\bpublished\b/.test(notice), + "a queued edit must not be described as published", + ); +}); diff --git a/desktop/src/features/agents/lib/personaSaveNotice.ts b/desktop/src/features/agents/lib/personaSaveNotice.ts new file mode 100644 index 0000000000..f75f0e1c68 --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.ts @@ -0,0 +1,24 @@ +import type { PersonaSharePublicationResult } from "@/shared/api/tauriPersonas"; + +/** + * The confirmation shown after a persona edit is saved. + * + * `publicationStatus` is null when the edit did not promise publication, so + * the copy stays silent about the catalog. When it did, the copy must + * distinguish a relay-accepted publish from a queued one — a "published" + * message for an edit still sitting in the outbox is the promise the + * "Save and publish" button was making falsely. + */ +export function personaSaveNotice( + displayName: string, + publicationStatus: PersonaSharePublicationResult["publicationStatus"] | null, +): string { + switch (publicationStatus) { + case "published": + return `Updated ${displayName} and published it to the community catalog.`; + case "queued": + return `Updated ${displayName}. Publishing to the community catalog is queued and will appear after the relay accepts the update.`; + default: + return `Updated ${displayName}.`; + } +} diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 7e30930900..e8e149ccd1 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -5,6 +5,7 @@ import { managedAgentsQueryKey, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; import { startManagedAgent, stopManagedAgent, @@ -109,6 +110,7 @@ export function useAutoRestartPolicy() { return; } await stopManagedAgent(agent.pubkey); + clearActiveTurnsForAgentOnStop(agent.pubkey); await startManagedAgent(agent.pubkey); } catch { // Failed attempt: edge stays consumed — badge-only until the diff --git a/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs b/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs new file mode 100644 index 0000000000..d65583e00d --- /dev/null +++ b/desktop/src/features/agents/lib/useInstallOutputLine.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nextInstallOutputLine } from "./useInstallOutputLine.ts"; + +function event(runtimeId, seq, line) { + return { runtime_id: runtimeId, seq, line }; +} + +test("nextInstallOutputLine: adopts the first line for the watched runtime", () => { + assert.deepEqual( + nextInstallOutputLine(null, event("goose", 0, "downloading"), "goose"), + { seq: 0, line: "downloading" }, + ); +}); + +test("nextInstallOutputLine: a later line replaces the current one", () => { + const current = { seq: 4, line: "downloading" }; + + assert.deepEqual( + nextInstallOutputLine(current, event("goose", 5, "unpacking"), "goose"), + { seq: 5, line: "unpacking" }, + ); +}); + +test("nextInstallOutputLine: ignores a line from another runtime", () => { + const current = { seq: 1, line: "downloading" }; + + assert.equal( + nextInstallOutputLine(current, event("codex", 2, "other work"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: ignores an out-of-order line", () => { + const current = { seq: 7, line: "retrying" }; + + assert.equal( + nextInstallOutputLine(current, event("goose", 6, "stale line"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: ignores a replay of the current sequence number", () => { + const current = { seq: 7, line: "retrying" }; + + assert.equal( + nextInstallOutputLine(current, event("goose", 7, "duplicate"), "goose"), + current, + ); +}); + +test("nextInstallOutputLine: a null line clears the display", () => { + const current = { seq: 3, line: "download failed" }; + + assert.deepEqual( + nextInstallOutputLine(current, event("goose", 4, null), "goose"), + { seq: 4, line: null }, + ); +}); + +test("nextInstallOutputLine: a later step's first line is adopted after a higher attempt", () => { + // The seq is install-wide: step 2 attempt 1 always follows step 1 attempt 2, + // which is exactly what an attempt-keyed comparison got wrong. + const current = { seq: 9, line: "step one, attempt two" }; + + assert.deepEqual( + nextInstallOutputLine( + current, + event("goose", 10, "step two, attempt one"), + "goose", + ), + { seq: 10, line: "step two, attempt one" }, + ); +}); + +test("nextInstallOutputLine: a first event mid-install is adopted", () => { + assert.deepEqual( + nextInstallOutputLine(null, event("goose", 42, "downloading"), "goose"), + { seq: 42, line: "downloading" }, + ); +}); diff --git a/desktop/src/features/agents/lib/useInstallOutputLine.ts b/desktop/src/features/agents/lib/useInstallOutputLine.ts new file mode 100644 index 0000000000..9f50843fc7 --- /dev/null +++ b/desktop/src/features/agents/lib/useInstallOutputLine.ts @@ -0,0 +1,108 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; + +/** Mirror of the Rust `InstallOutputEvent` payload (install_report.rs). */ +export type InstallOutputEvent = { + runtime_id: string; + /** Monotonic across the whole install, not per step or per attempt. */ + seq: number; + /** Null is the start signal: clear the displayed line now. */ + line: string | null; +}; + +/** The line being shown, and the sequence number that produced it. */ +export type InstallOutputState = { + seq: number; + line: string | null; +}; + +/** + * Fold one event into the displayed line. + * + * Events from another runtime are ignored — every install card listens to the + * same channel. So is an out-of-order event: emission is monotonic in `seq`, so + * a lower one has already been superseded. That matters at a retry boundary, + * where a line emitted just as the next attempt starts would otherwise sit + * under the spinner showing the failure the user already had. + * + * The ordering key is the install-wide `seq` rather than the attempt number, + * which restarts at 1 for every step: keyed on attempt, a step that succeeded on + * attempt 2 would make the next step's attempt-1 output look stale and freeze + * the display for the rest of the install. + */ +export function nextInstallOutputLine( + current: InstallOutputState | null, + event: InstallOutputEvent, + runtimeId: string, +): InstallOutputState | null { + if (event.runtime_id !== runtimeId) return current; + if (current && event.seq <= current.seq) return current; + return { seq: event.seq, line: event.line }; +} + +/** + * The install command's most recent output line for `runtimeId`, or null when + * nothing is being shown — the install is not running, nothing has printed yet, + * or the backend cleared the line because a new attempt is starting. + * + * An install runs for up to 15 minutes with no other feedback than a spinner; + * this turns that wait into observable progress. The backend throttles + * emission, so this re-renders a few times a second at most. + * + * Pass `isInstalling` so the line clears when the install settles — a finished + * install must not leave its last line under a fresh Install button. + */ +export function useInstallOutputLine( + runtimeId: string, + isInstalling: boolean, +): string | null { + const [state, setState] = React.useState(null); + + // Subscribed for this runtime's whole lifetime, not just while installing. + // The install command is invoked from the click handler, so the backend can + // emit the attempt-start clear and the first line before React commits + // `isInstalling` — and there is no replay, so a subscription that waited for + // that commit would lose those events permanently. A fast command's entire + // output is exactly what fits in that window. + React.useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + (async () => { + try { + const stop = await listen( + "acp-install-output", + (event) => { + if (cancelled) return; + setState((current) => + nextInstallOutputLine(current, event.payload, runtimeId), + ); + }, + ); + if (cancelled) { + stop(); + } else { + unlisten = stop; + } + } catch { + // Event system unavailable (web/e2e) — the spinner shows alone. + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [runtimeId]); + + // `seq` is monotonic within one install and restarts at 0 for the next, so + // state must not outlive the run that produced it: a retained higher `seq` + // would make every event of the following install look superseded. Settling + // is the run boundary, so it is where the ordering key resets. + React.useEffect(() => { + if (!isInstalling) setState(null); + }, [isInstalling]); + + // Events that arrive after the install settles — a drain flushing its last + // line — must not reappear under a fresh Install button, so the line is + // reported only while the install is running. + return (isInstalling ? state?.line : null) ?? null; +} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 0000000000..c7835f9373 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { invalidatePersonaEditCaches } from "@/features/agents/lib/personaEditCaches"; +import { relayClient } from "@/shared/api/relayClient"; +import { + setPersonaShared, + updatePersonaAndPublish, +} from "@/shared/api/tauriPersonas"; +import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; +import { KIND_PERSONA } from "@/shared/constants/kinds"; + +export function personaCatalogQueryKey(communityId: string | null) { + return ["persona-catalog", communityId] as const; +} + +export function usePersonaCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: personaCatalogQueryKey(communityId), + queryFn: fetchPersonaCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function usePersonaCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive({ kinds: [KIND_PERSONA], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Couldn’t subscribe to the community agent catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function useSetPersonaCatalogSharedMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, shared }: { id: string; shared: boolean }) => + setPersonaShared(id, shared), + onSuccess: (result) => { + queryClient.setQueryData( + ["personas"], + (current) => + current?.map((persona) => + persona.id === result.persona.id ? result.persona : persona, + ) ?? [result.persona], + ); + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }, + }); +} + +/** + * Save a persona edit and publish its catalog head, reporting the relay's + * verdict. + * + * The plain edit mutation only enqueues the head best-effort, so it cannot back + * the "Save and publish" promise. This awaits the relay and additionally + * refreshes the catalog query, since the published edit changes what the + * catalog shows. + */ +export function useUpdatePersonaAndPublishMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdatePersonaInput) => updatePersonaAndPublish(input), + onSettled: async (_data, _error, variables) => { + await Promise.all([ + invalidatePersonaEditCaches(queryClient, variables.id), + queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }), + ]); + }, + }); +} diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a1cbbf93fe..0dc12ddfd1 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -35,7 +35,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { return Promise.resolve(() => Promise.resolve()); }); - startPersonaSync("owner-pubkey", () => false); + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); assert.deepEqual( @@ -58,3 +58,53 @@ test("startPersonaSync backfills history including the deletion kind", () => { mock.reset(); }); + +// Regression guard for the arrival-scope fix (F6): the reconcile must carry the +// relay this subscription was opened on, NOT whichever community happens to be +// active when the reconcile runs. Without the forwarded URL the backend falls +// back to the active workspace and an in-flight event lands in the wrong +// community's scoped retention store on a mid-flight switch. +test("startPersonaSync forwards its own relay as the event arrival relay", async () => { + const invokes = []; + // @tauri-apps/api/core reads `window.__TAURI_INTERNALS__.invoke`. + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + + const ownEvent = { id: "e1", pubkey: "owner-pubkey", kind: KIND_PERSONA }; + const foreignEvent = { id: "e2", pubkey: "someone-else", kind: KIND_PERSONA }; + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent, foreignEvent]), + ); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://community-a.example", () => false); + // Let the backfill promise chain and the reconcile invoke settle. + await new Promise((resolve) => setImmediate(resolve)); + + const reconciles = invokes.filter( + (call) => call.cmd === "reconcile_inbound_persona_event", + ); + assert.equal( + reconciles.length, + 1, + "only the subscribed author's event reconciles", + ); + assert.equal( + reconciles[0].args.arrivalRelayUrl, + "wss://community-a.example", + "reconcile must carry the subscription's relay as the arrival relay", + ); + assert.equal(JSON.parse(reconciles[0].args.eventJson).id, "e1"); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index e713ed71d1..f18194c5c6 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,19 +20,28 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; -// Start the persona/team/agent/deletion sync for `pubkey`: one-shot backfill -// of existing heads + tombstones, then a live subscription. Returns a disposer -// that closes the live subscription. Extracted from the hook so the wiring is -// unit-testable without a React renderer (see `usePersonaSync.test.mjs`). +// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: +// one-shot backfill of existing heads + tombstones, then a live subscription. +// Returns a disposer that closes the live subscription. Extracted from the hook +// so the wiring is unit-testable without a React renderer (see +// `usePersonaSync.test.mjs`). +// +// `relayUrl` is the community this subscription is bound to, and every reconcile +// carries it as the event's arrival relay. Capturing it here — rather than +// letting the backend read whichever workspace is active when the reconcile runs +// — is what keeps an in-flight event out of the next community's scoped store. export function startPersonaSync( pubkey: string, + relayUrl: string, onCancelled: () => boolean, ): () => Promise { const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event)).catch((error) => { - console.warn("[usePersonaSync] reconcile failed:", error); - }); + void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( + (error) => { + console.warn("[usePersonaSync] reconcile failed:", error); + }, + ); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start @@ -68,23 +77,27 @@ export function startPersonaSync( // Subscribes to this device's own persona/team/agent projection + deletion // events and patches each into the local store. The subscription is keyed on -// the active pubkey: an identity switch re-runs the effect, whose cleanup -// closes the old subscription before a new one opens on the new pubkey's -// filter — so no stale-coordinate subscription survives. +// the active pubkey and relay: an identity or community switch re-runs the +// effect, whose cleanup closes the old subscription before a new one opens on +// the new filter — so no stale-coordinate subscription survives, and every +// reconcile is attributed to the community it was subscribed to. // // A fresh device that comes online AFTER another already published gets no // history from a live-only subscription: relayClient's replayLiveSubscriptions // only replays from a since-cursor that is undefined until the first live // event arrives. So `startPersonaSync` does an explicit one-shot history fetch // up front and feeds each event through the same reconcile path. -export function usePersonaSync(pubkey: string | undefined): void { +export function usePersonaSync( + pubkey: string | undefined, + relayUrl: string | undefined, +): void { React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - const dispose = startPersonaSync(pubkey, () => cancelled); + const dispose = startPersonaSync(pubkey, relayUrl, () => cancelled); return () => { cancelled = true; void dispose(); }; - }, [pubkey]); + }, [pubkey, relayUrl]); } diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs new file mode 100644 index 0000000000..3e961b5854 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { restartManagedAgentPair } from "./managedAgentRuntimeHooks.ts"; + +// --------------------------------------------------------------------------- +// restartManagedAgentPair: discriminating regression tests for the pair +// restart lifecycle boundary (stop → relay-scoped clear → start). +// +// These tests exercise the exact function called by useManagedAgentRuntimeAction's +// mutationFn restart branch, so reverting to the old combined Rust command +// (which cleared only in onSuccess, after the new process was already running) +// would make tests (a) and (c) fail. +// --------------------------------------------------------------------------- + +const PUBKEY = "deadbeef".repeat(8); +const RELAY = "wss://relay.example"; + +/** Returns a resolved-status stub sufficient for the return-type assertion. */ +function makeStatus() { + return { + pubkey: PUBKEY, + relayUrl: RELAY, + localSetup: true, + lifecycle: "running", + }; +} + +test("test_pair_restart_stop_success_start_failure_clear_still_ran", async () => { + // Stop succeeds, start throws. The clear must have fired — badge is gone + // regardless of the start failure. On the old combined-command approach, + // a rejected command meant onSuccess never ran and the badge survived. + let clearFired = false; + + await assert.rejects( + restartManagedAgentPair( + PUBKEY, + RELAY, + async () => makeStatus(), // stop succeeds + (_pubkey, _relayUrl) => { + clearFired = true; + }, + async () => { + throw new Error("start failed"); + }, + ), + /start failed/, + ); + + assert.ok( + clearFired, + "clear must fire at stop-success boundary even when start subsequently fails", + ); +}); + +test("test_pair_restart_stop_failure_neither_clear_nor_start_called", async () => { + // Stop throws. Neither clear nor start should run — clearing on a failed + // stop would remove a badge that is still legitimately active. + let clearFired = false; + let startCalled = false; + + await assert.rejects( + restartManagedAgentPair( + PUBKEY, + RELAY, + async () => { + throw new Error("stop failed"); + }, + (_pubkey, _relayUrl) => { + clearFired = true; + }, + async () => { + startCalled = true; + return makeStatus(); + }, + ), + /stop failed/, + ); + + assert.ok(!clearFired, "clear must NOT fire when stop itself fails"); + assert.ok(!startCalled, "start must NOT be called when stop fails"); +}); + +test("test_pair_restart_strict_stop_clear_start_ordering", async () => { + // Verify the operations fire in the guaranteed order: stop → clear → start. + // A clear that fires after start begins can tombstone genuine new turns. + const events = []; + + await restartManagedAgentPair( + PUBKEY, + RELAY, + async () => { + events.push("stop"); + return makeStatus(); + }, + (_pubkey, _relayUrl) => { + events.push("clear"); + }, + async () => { + events.push("start"); + return makeStatus(); + }, + ); + + assert.deepEqual( + events, + ["stop", "clear", "start"], + "operations must fire in stop → clear → start order", + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index aef9c8c1e8..96a3abc78d 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -5,15 +5,19 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { loadCommunities } from "@/features/communities/communityStorage"; +import { clearActiveTurnsForAgent } from "@/features/agents/activeAgentTurnsStore"; +import { + loadActiveCommunityId, + loadCommunities, +} from "@/features/communities/communityStorage"; import { listManagedAgentRuntimes, reconcileManagedAgentRuntimes, - restartManagedAgentRuntime, startManagedAgentRuntime, stopManagedAgentRuntime, } from "@/shared/api/tauriManagedAgents"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; +import { canonicalRelayUrl } from "./managedAgentRuntimeStatus"; export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const; @@ -101,6 +105,79 @@ export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) { }); } +/** + * Clear the active community's working badges for an agent when Desktop + * performs an agent-wide stop or restart that does not go through the + * pair-scoped `useManagedAgentRuntimeAction` mutation. Applies the same + * relay-scope gate: only wipes the store when the active community relay + * matches `relayUrl`, or — for agent-wide operations with no known relay — + * when any of the agent's configured pairs is in the active community. + * + * Pass `relayUrl` when a specific pair relay is known (preferred). Omit it + * (pass null/undefined) for agent-wide operations: the function then clears + * whenever the active community is configured, since the agent-wide stop + * affects all pairs, including the one in the active community. + */ +export function clearActiveTurnsForAgentOnStop( + pubkey: string, + relayUrl?: string | null, +): void { + const activeId = loadActiveCommunityId(); + if (!activeId) return; + const activeCommunity = loadCommunities().find((c) => c.id === activeId); + if (!activeCommunity) return; + + if (relayUrl != null) { + // Pair-scoped: only clear when the stopped pair's relay matches the active + // community. A mismatch means the stop targets a different community's + // store — leave it alone. + const activeCanonical = canonicalRelayUrl(activeCommunity.relayUrl); + const stoppedCanonical = canonicalRelayUrl(relayUrl); + if ( + activeCanonical === null || + stoppedCanonical === null || + activeCanonical !== stoppedCanonical + ) { + return; + } + } + // Agent-wide (relayUrl omitted): active community is confirmed to exist, so + // the stop affects the active pair among others — clear. + + clearActiveTurnsForAgent(pubkey); +} + +/** + * Execute a pair restart as stop → relay-scoped badge clear → start. + * + * Extracted from `useManagedAgentRuntimeAction`'s `mutationFn` so the + * three-step lifecycle boundary can be tested directly without a hook-render + * harness. All three operations are injected, keeping this function free of + * React and Tauri imports. + * + * Guarantees: + * - Clear fires only when stop succeeds. + * - A failed start occurs after the clear — the badge is already gone. + * - No clear can fire after start begins, so genuinely-new turns are safe. + */ +export async function restartManagedAgentPair( + pubkey: string, + relayUrl: string, + stop: ( + pubkey: string, + relayUrl: string, + ) => Promise, + clear: (pubkey: string, relayUrl: string) => void, + start: ( + pubkey: string, + relayUrl: string, + ) => Promise, +): Promise { + await stop(pubkey, relayUrl); + clear(pubkey, relayUrl); + return start(pubkey, relayUrl); +} + export function useManagedAgentRuntimeAction() { const queryClient = useQueryClient(); return useMutation({ @@ -115,11 +192,23 @@ export function useManagedAgentRuntimeAction() { }) => { if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); if (action === "restart") { - return restartManagedAgentRuntime(pubkey, relayUrl); + return restartManagedAgentPair( + pubkey, + relayUrl, + stopManagedAgentRuntime, + clearActiveTurnsForAgentOnStop, + startManagedAgentRuntime, + ); } return startManagedAgentRuntime(pubkey, relayUrl); }, - onSuccess: (runtime) => { + onSuccess: (runtime, { action }) => { + // For stop-only: clear stale working badges immediately. The restart + // path already clears at the stop-success boundary inside mutationFn. + if (action === "stop") { + clearActiveTurnsForAgentOnStop(runtime.pubkey, runtime.relayUrl); + } + queryClient.setQueryData( managedAgentRuntimesQueryKey, (current = []) => { diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a9f2734f21..c3a952f7d5 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -57,9 +57,9 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< ManagedAgentPairAction, string > = { - start: "Start", - stop: "Stop", - restart: "Restart", + start: "Start Agent", + stop: "Stop Agent", + restart: "Restart Agent", }; /** diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 56c69f915a..611fdd489d 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -338,6 +338,71 @@ export function isObserverEventAfter( return candidate.seq > stored.seq; } +// Observer event kind for a batch envelope wrapping multiple events. The ACP +// harness publishes one frame per second; everything that accumulated between +// ticks arrives as `{ kind: "batch", payload: { events: [...] } }` with every +// inner event carrying its own seq/timestamp. Inner events are processed +// exactly as unbatched ones; the envelope itself is never stored. +const OBSERVER_BATCH_KIND = "batch"; + +// Expand a decrypted observer event into its inner events when it is a batch +// envelope; a non-batch event passes through as a single-element array. A +// malformed envelope (no events array) degrades to the envelope itself so a +// harness bug cannot silently blank the session viewer. +function unwrapObserverBatch(parsed: ObserverEvent): ObserverEvent[] { + if (parsed.kind !== OBSERVER_BATCH_KIND) { + return [parsed]; + } + const payload = parsed.payload as { events?: unknown } | null; + const events = Array.isArray(payload?.events) + ? (payload.events as ObserverEvent[]) + : null; + return events && events.length > 0 ? events : [parsed]; +} + +// Per-event processing shared by every event a live frame carries (one for a +// plain frame, many for a batch envelope). +function processLiveObserverEvent(agentPubkey: string, parsed: ObserverEvent) { + // Track the latest-live-session-id per (agent, channel) on the live path. + // Only set when the parsed event carries both a sessionId and channelId, + // so we never attribute a session to the wrong channel. + if (parsed.sessionId && parsed.channelId) { + const key = liveSessionKey(agentPubkey, parsed.channelId); + const stored = latestLiveSessionByAgentChannel.get(key); + // Advance only when this event sorts strictly AFTER the stored one via + // isObserverEventAfter (timestamp then seq — same ordering as + // compareObserverEvents). This prevents late-arriving live frames from + // older sessions from regressing the latest-live id, while also + // correctly advancing on a same-timestamp frame with a higher seq. + if (!stored || isObserverEventAfter(parsed, stored)) { + latestLiveSessionByAgentChannel.set(key, { + sessionId: parsed.sessionId, + timestamp: parsed.timestamp, + seq: parsed.seq, + }); + } + } + appendAgentEvent(agentPubkey, parsed); + const managementRequest = parseAgentManagementRequest(parsed.payload); + if (managementRequest) { + for (const listener of agentManagementListeners) { + listener(agentPubkey, managementRequest); + } + } + if (parsed.kind === "session_config_captured") { + void putAgentSessionConfig(agentPubkey, parsed.payload); + onSessionConfigCaptured?.(agentPubkey); + } else if (parsed.kind === "control_result") { + dispatchControlResult(agentPubkey, parsed.payload); + } else if (parsed.kind === "managed_agent_runtime_lifecycle") { + void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( + (error) => { + console.debug("Late/untracked lifecycle frame dropped:", error); + }, + ); + } +} + async function handleRelayObserverEvent( event: RelayEvent, activeGeneration: number, @@ -372,43 +437,8 @@ async function handleRelayObserverEvent( if (activeGeneration !== generation) { return; } - // Track the latest-live-session-id per (agent, channel) on the live path. - // Only set when the parsed event carries both a sessionId and channelId, - // so we never attribute a session to the wrong channel. - if (parsed.sessionId && parsed.channelId) { - const key = liveSessionKey(agentPubkey, parsed.channelId); - const stored = latestLiveSessionByAgentChannel.get(key); - // Advance only when this event sorts strictly AFTER the stored one via - // isObserverEventAfter (timestamp then seq — same ordering as - // compareObserverEvents). This prevents late-arriving live frames from - // older sessions from regressing the latest-live id, while also - // correctly advancing on a same-timestamp frame with a higher seq. - if (!stored || isObserverEventAfter(parsed, stored)) { - latestLiveSessionByAgentChannel.set(key, { - sessionId: parsed.sessionId, - timestamp: parsed.timestamp, - seq: parsed.seq, - }); - } - } - appendAgentEvent(agentPubkey, parsed); - const managementRequest = parseAgentManagementRequest(parsed.payload); - if (managementRequest) { - for (const listener of agentManagementListeners) { - listener(agentPubkey, managementRequest); - } - } - if (parsed.kind === "session_config_captured") { - void putAgentSessionConfig(agentPubkey, parsed.payload); - onSessionConfigCaptured?.(agentPubkey); - } else if (parsed.kind === "control_result") { - dispatchControlResult(agentPubkey, parsed.payload); - } else if (parsed.kind === "managed_agent_runtime_lifecycle") { - void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( - (error) => { - console.debug("Late/untracked lifecycle frame dropped:", error); - }, - ); + for (const inner of unwrapObserverBatch(parsed)) { + processLiveObserverEvent(agentPubkey, inner); } } catch (error) { if (activeGeneration !== generation) { @@ -676,20 +706,22 @@ export async function ingestArchivedObserverEvents( } try { const parsed = (await _decryptFn(event)) as ObserverEvent; - // Route archived events to the channel-scoped archive window (no cap) - // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). - // Events without a channelId fall through to the live store so they - // remain visible in the agent's general transcript. - if (parsed.channelId) { - const added = appendArchivedChannelEvent( - agentPubkey, - parsed.channelId, - parsed, - ); - if (added) archiveChanged = true; - } else { - // Live path already calls notifyListeners() inside appendAgentEvent. - appendAgentEvent(agentPubkey, parsed); + for (const inner of unwrapObserverBatch(parsed)) { + // Route archived events to the channel-scoped archive window (no cap) + // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). + // Events without a channelId fall through to the live store so they + // remain visible in the agent's general transcript. + if (inner.channelId) { + const added = appendArchivedChannelEvent( + agentPubkey, + inner.channelId, + inner, + ); + if (added) archiveChanged = true; + } else { + // Live path already calls notifyListeners() inside appendAgentEvent. + appendAgentEvent(agentPubkey, inner); + } } } catch { // Silently drop decrypt failures — same as live path error handling. diff --git a/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx new file mode 100644 index 0000000000..0c84e99275 --- /dev/null +++ b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx @@ -0,0 +1,47 @@ +import { CustomHarnessForm } from "@/features/settings/ui/CustomHarnessForm"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +/** + * Registers a custom ACP harness from inside an agent dialog, so "New agent" + * is a complete entry point and not a dead end that sends the user to + * Settings. Hosts the same `CustomHarnessForm` the harness catalog uses. + */ +export function AddCustomHarnessDialog({ + onOpenChange, + onSaved, + open, +}: { + onOpenChange: (open: boolean) => void; + /** Called with the id of the harness that was just registered. */ + onSaved: (id: string) => void; + open: boolean; +}) { + return ( + + + + Register any ACP-speaking agent tool as a selectable harness. +

+ } + onCancel={() => onOpenChange(false)} + onSaved={(id) => { + // Dismiss on save as well as cancel — both exits belong to this + // dialog, so callers only handle the resulting selection. + onOpenChange(false); + onSaved(id); + }} + /> +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs new file mode 100644 index 0000000000..ff3efaec92 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Tests for the key-panel visibility derivations that AgentCardMintDialog +// imports from cardMintKeyUtils. These tests exercise the exact production +// module — changes to any exported function will cause failures here. + +import { + isReadOnlyLayer, + isWritableLayer, + keyPanelTitle, + showCancelButton, + showKeyPanel, + showKeyStatusRow, + showReadOnlyRow, +} from "./cardMintKeyUtils.ts"; + +describe("cardMintKeyUtils — key panel derivations", () => { + // ── isWritableLayer ──────────────────────────────────────────────────────── + + it("isWritableLayer_none_true", () => { + assert.equal(isWritableLayer("none"), true); + }); + + it("isWritableLayer_global_true", () => { + assert.equal(isWritableLayer("global"), true); + }); + + it("isWritableLayer_agent_false", () => { + assert.equal(isWritableLayer("agent"), false); + }); + + it("isWritableLayer_persona_false", () => { + assert.equal(isWritableLayer("persona"), false); + }); + + it("isWritableLayer_process_false", () => { + assert.equal(isWritableLayer("process"), false); + }); + + it("isWritableLayer_undefined_false", () => { + // Unknown (pending/error) — don't offer a write path + assert.equal(isWritableLayer(undefined), false); + }); + + // ── isReadOnlyLayer ──────────────────────────────────────────────────────── + + it("isReadOnlyLayer_agent_true", () => { + assert.equal(isReadOnlyLayer("agent"), true); + }); + + it("isReadOnlyLayer_persona_true", () => { + assert.equal(isReadOnlyLayer("persona"), true); + }); + + it("isReadOnlyLayer_process_true", () => { + assert.equal(isReadOnlyLayer("process"), true); + }); + + it("isReadOnlyLayer_global_false", () => { + assert.equal(isReadOnlyLayer("global"), false); + }); + + it("isReadOnlyLayer_none_false", () => { + assert.equal(isReadOnlyLayer("none"), false); + }); + + it("isReadOnlyLayer_undefined_false", () => { + assert.equal(isReadOnlyLayer(undefined), false); + }); + + // ── showKeyPanel ─────────────────────────────────────────────────────────── + // Key panel replaces the mint form ONLY for first-time setup (none) or + // user-initiated editing (editingKey). Read-only layers do NOT replace the + // mint form — they show an inline status row instead. + + it("showKeyPanel_none_notEditing_shows", () => { + // First-time user: key not set → show setup panel + assert.equal(showKeyPanel("none", false), true); + }); + + it("showKeyPanel_global_notEditing_hides", () => { + // Normal state: key in global defaults, not editing → show mint form + assert.equal(showKeyPanel("global", false), false); + }); + + it("showKeyPanel_global_editing_shows", () => { + // User clicked Update → show the update panel + assert.equal(showKeyPanel("global", true), true); + }); + + it("showKeyPanel_agent_notEditing_hides", () => { + // Read-only layer: mint form stays visible; inline row shown instead + assert.equal(showKeyPanel("agent", false), false); + }); + + it("showKeyPanel_persona_notEditing_hides", () => { + assert.equal(showKeyPanel("persona", false), false); + }); + + it("showKeyPanel_process_notEditing_hides", () => { + assert.equal(showKeyPanel("process", false), false); + }); + + it("showKeyPanel_agent_editing_shows", () => { + // User clicked Why? on a read-only row → show the redirect panel + assert.equal(showKeyPanel("agent", true), true); + }); + + it("showKeyPanel_persona_editing_shows", () => { + assert.equal(showKeyPanel("persona", true), true); + }); + + it("showKeyPanel_process_editing_shows", () => { + assert.equal(showKeyPanel("process", true), true); + }); + + it("showKeyPanel_undefined_notEditing_hides", () => { + // Query pending/error → show mint form (fail-open, no panel claim) + assert.equal(showKeyPanel(undefined, false), false); + }); + + // ── showCancelButton ─────────────────────────────────────────────────────── + + it("showCancelButton_global_editing_shows", () => { + // Update mode for a global key: Cancel returns to the mint form + assert.equal(showCancelButton("global", true), true); + }); + + it("showCancelButton_none_editing_hides", () => { + // First-time setup: no cancel (no mint form to return to) + assert.equal(showCancelButton("none", true), false); + }); + + it("showCancelButton_global_notEditing_hides", () => { + assert.equal(showCancelButton("global", false), false); + }); + + it("showCancelButton_agent_editing_shows", () => { + // Read-only layer + user clicked Why?: Cancel returns to the mint form + assert.equal(showCancelButton("agent", true), true); + }); + + it("showCancelButton_persona_editing_shows", () => { + assert.equal(showCancelButton("persona", true), true); + }); + + it("showCancelButton_process_editing_shows", () => { + assert.equal(showCancelButton("process", true), true); + }); + + // ── showKeyStatusRow ─────────────────────────────────────────────────────── + + it("showKeyStatusRow_global_notEditing_shows", () => { + // Confirmed writable key: show "Using your saved OpenAI key · Update" + assert.equal(showKeyStatusRow("global", false), true); + }); + + it("showKeyStatusRow_global_editing_hides", () => { + // In update panel: status row is redundant while editing + assert.equal(showKeyStatusRow("global", true), false); + }); + + it("showKeyStatusRow_none_notEditing_hides", () => { + // No key: show setup panel, not status row + assert.equal(showKeyStatusRow("none", false), false); + }); + + it("showKeyStatusRow_agent_notEditing_hides", () => { + // Read-only layer: use showReadOnlyRow instead + assert.equal(showKeyStatusRow("agent", false), false); + }); + + it("showKeyStatusRow_undefined_notEditing_hides", () => { + // Query pending/error: do not assert key existence + assert.equal(showKeyStatusRow(undefined, false), false); + }); + + // ── showReadOnlyRow ──────────────────────────────────────────────────────── + // Inline provenance row on the mint form for keys the dialog cannot update. + + it("showReadOnlyRow_agent_notEditing_shows", () => { + assert.equal(showReadOnlyRow("agent", false), true); + }); + + it("showReadOnlyRow_persona_notEditing_shows", () => { + assert.equal(showReadOnlyRow("persona", false), true); + }); + + it("showReadOnlyRow_process_notEditing_shows", () => { + assert.equal(showReadOnlyRow("process", false), true); + }); + + it("showReadOnlyRow_agent_editing_hides", () => { + // User clicked Why? → redirect panel shown; row hidden + assert.equal(showReadOnlyRow("agent", true), false); + }); + + it("showReadOnlyRow_global_notEditing_hides", () => { + // Global key uses showKeyStatusRow instead + assert.equal(showReadOnlyRow("global", false), false); + }); + + it("showReadOnlyRow_none_hides", () => { + assert.equal(showReadOnlyRow("none", false), false); + }); + + it("showReadOnlyRow_undefined_hides", () => { + assert.equal(showReadOnlyRow(undefined, false), false); + }); + + // ── Mint-reachability invariant ──────────────────────────────────────────── + // The mint form (and Mint button) must be reachable whenever a key resolves. + // showKeyPanel returns true only for setup (none) or user-initiated edit. + + it("mintReachable_global_noEdit", () => { + assert.equal(showKeyPanel("global", false), false); + }); + + it("mintReachable_agent_noEdit", () => { + assert.equal(showKeyPanel("agent", false), false); + }); + + it("mintReachable_persona_noEdit", () => { + assert.equal(showKeyPanel("persona", false), false); + }); + + it("mintReachable_process_noEdit", () => { + assert.equal(showKeyPanel("process", false), false); + }); + + it("mintBlocked_none_noEdit", () => { + // Only when no key is set at all does the panel gate minting + assert.equal(showKeyPanel("none", false), true); + }); + + // ── keyPanelTitle ────────────────────────────────────────────────────────── + + it("keyPanelTitle_none_firstTimeSetup", () => { + assert.equal( + keyPanelTitle("none", false), + "One-time setup: OpenAI API key", + ); + }); + + it("keyPanelTitle_global_editing_update", () => { + assert.equal(keyPanelTitle("global", true), "Update OpenAI API key"); + }); + + it("keyPanelTitle_agent_readOnly", () => { + assert.equal(keyPanelTitle("agent", false), "OpenAI API key"); + }); + + it("keyPanelTitle_persona_readOnly", () => { + assert.equal(keyPanelTitle("persona", true), "OpenAI API key"); + }); +}); diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx new file mode 100644 index 0000000000..b2bffc00df --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -0,0 +1,463 @@ +import * as React from "react"; +import { + AlertCircle, + Brain, + ExternalLink, + GalleryVerticalEnd, + KeyRound, + Lock, + Sparkles, +} from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { + setCardGalleryOpen, + startCardMint, +} from "@/features/agents/cardMintStore"; +import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; +import { + cardMintKeyStatus, + cardMintSaveOpenaiKey, + type CardMintKeyLayer, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Switch } from "@/shared/ui/switch"; +import { Textarea } from "@/shared/ui/textarea"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; +import { + isReadOnlyLayer, + keyPanelTitle, + showCancelButton, + showKeyPanel, + showKeyStatusRow, + showReadOnlyRow, +} from "./cardMintKeyUtils"; + +const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys"; + +/** Same three levels as snapshot export; "Agent only" is the safe default. */ +const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [ + { value: "none", label: "Agent only" }, + { value: "core", label: "Agent + core memory" }, + { value: "everything", label: "Agent + all memories" }, +]; + +/** + * The free alternative, as an action: ordinary snapshot export shares the + * same importable agent without card art or API spend. Rendered in both the + * key-setup panel and the normal pre-mint form (the cost disclosure and its + * escape hatch must be visible BEFORE any spend, not only during onboarding). + */ +function FreeSharePathRow({ + disabled, + onExportInstead, +}: { + disabled: boolean; + onExportInstead?: () => void; +}) { + return ( +
+

+ Don’t want to spend money? Ordinary export shares the same importable + agent — free, just without the card art. +

+ {onExportInstead ? ( + + ) : null} +
+ ); +} + +/** + * Mint-a-trading-card dialog — the pre-mint half only: key setup (when + * needed) → optional style notes → "Mint card". Minting itself runs as a + * background job in `cardMintStore`: this dialog dispatches and closes, the + * composer activity rail shows live status, and the finished card opens in + * the global `AgentCardViewerDialog` (preview, reroll, save, share). + * + * The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing + * the card shares an importable agent (fresh identity, never secrets; memory + * only when the owner opts in below — plaintext unless the card is locked). + * All snapshot construction and verification happens in Rust. + */ +export function AgentCardMintDialog({ + agentId, + agentName, + canLock, + onExportInstead, + onOpenChange, +}: { + /** Instance pubkey or definition slug — same resolution as snapshot export. */ + agentId: string; + agentName: string; + /** + * True when the agent has a linked instance (a keypair to lock to). + * Locking is disabled — with an explanation — for bare definitions. + */ + canLock: boolean; + /** + * Free alternative: close this dialog and open the ordinary snapshot + * export flow (no API spend). Omitted = the action is not rendered. + */ + onExportInstead?: () => void; + onOpenChange: (open: boolean) => void; +}) { + const [styleNotes, setStyleNotes] = React.useState(""); + const [lockCard, setLockCard] = React.useState(false); + const [memoryLevel, setMemoryLevel] = + React.useState("none"); + const [keyDraft, setKeyDraft] = React.useState(""); + const [editingKey, setEditingKey] = React.useState(false); + + const queryClient = useQueryClient(); + + const effectiveLock = canLock && lockCard; + // Embedded memory is plaintext in an unlocked card — and unlocked cards + // are meant to be shared. A locked card encrypts the whole manifest to the + // (owner, agent) pair, so the plaintext warning would be false there. + const showMemoryWarning = memoryLevel !== "none" && !effectiveLock; + + // Whether a key already resolves through the agent's env layering, and from + // which layer. While unknown (loading/error) we treat as if no verified key + // exists — mint still works fail-open, but we don't assert a key is present. + const keyStatusQuery = useQuery({ + queryKey: ["cardMintKeyStatus", agentId], + queryFn: () => cardMintKeyStatus(agentId), + }); + const keyLayer: CardMintKeyLayer | undefined = keyStatusQuery.data; + // True when the key resolves from a layer this dialog cannot update. + const keyIsReadOnly = isReadOnlyLayer(keyLayer); + + // Save the pasted key into the global Agent Defaults env — the same single + // source of truth every agent inherits. Narrow Rust seam: validated + // single-key merge, never restarts running agents (the mint re-reads + // config per call, so no restart is needed for minting). + const saveKeyMutation = useMutation({ + mutationFn: (key: string) => cardMintSaveOpenaiKey(key), + onSuccess: () => { + // The key now lives in global defaults — update the cached layer so the + // status row shows correctly without waiting for a refetch. + queryClient.setQueryData( + ["cardMintKeyStatus", agentId], + "global", + ); + // The Agent Defaults editor caches the whole config — refetch it so a + // later-opened settings view shows the key we just wrote. + void queryClient.invalidateQueries({ + queryKey: globalAgentConfigQueryKey, + }); + setKeyDraft(""); + setEditingKey(false); + toast.success( + "API key saved to your agent defaults. Running agents pick it up on their next restart.", + ); + }, + onError: (error) => + toast.error(typeof error === "string" ? error : "Couldn't save the key."), + }); + + function beginMint() { + // Dispatch to the background store and close: the composer rail shows + // "Minting card…" and the completion toast opens the viewer. + startCardMint({ + agentId, + agentName, + styleNotes: styleNotes.trim() || undefined, + lock: effectiveLock, + memoryLevel: canLock ? memoryLevel : "none", + }); + onOpenChange(false); + } + + return ( + + + + + + {`Create ${agentName}'s card`} + + + Mint a collectible trading card that doubles as a shareable, + importable copy of this agent. + + + + {showKeyPanel(keyLayer, editingKey) ? ( +
+
+ + + {keyPanelTitle(keyLayer, editingKey)} + + {keyIsReadOnly ? ( + // Key resolves from a layer the dialog cannot write to — show + // a read-only redirect instead of an input that would be + // shadowed by the higher-priority layer. +

+ {keyLayer === "agent" + ? "This agent's OpenAI key is set in its own agent settings — update it there." + : keyLayer === "persona" + ? "This agent's OpenAI key comes from its linked persona settings — update it there." + : "This agent's OpenAI key is set in the process environment — update it in your shell or launch config."} +

+ ) : ( + <> +

+ Minting a card costs money — it generates the art and card + text through the OpenAI API with your key (typically well + under a dollar per mint, billed by OpenAI). The key is saved + as OPENAI_API_KEY in your + agent defaults env — that's the row to update in Settings if + you ever need to change it there. +

+ + setKeyDraft(e.target.value)} + placeholder="sk-…" + type="password" + value={keyDraft} + /> + + )} +
+ +
+ {showCancelButton(keyLayer, editingKey) ? ( + + ) : null} + {!keyIsReadOnly ? ( + + ) : null} +
+
+ ) : ( +
+
+