diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 00000000000..0acd8c61f0d --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,46 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +.jsonl.lock +sync_base.jsonl +export-state/ + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 00000000000..50f281f032b --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 00000000000..ff8bc92106f --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,67 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000000..54628495272 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,15 @@ +{"id":"ios-buzz-59e","title":"Fix iOS peer presence hydration","description":"Hydrate tracked iOS peer presence from authenticated relay queries before the next ephemeral heartbeat. Deliver through TDD, iOS 26.5 simulator acceptance, merged source and Notion documentation, and guarded cleanup.","status":"in_progress","priority":0,"issue_type":"epic","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:43.874684-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-09T21:44:45.983024-07:00"} +{"id":"ios-buzz-59e.1","title":"Verify relay, CLI, toolchain, and documentation prerequisites","description":"Verify GitHub, Notion, Buzz presence authority, Flutter/Xcode, cached dependencies, and available iOS simulator without exposing identity material.","status":"closed","priority":0,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:44.064167-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-09T21:46:21.746758-07:00","closed_at":"2026-08-09T21:46:21.746758-07:00","close_reason":"Closed","dependencies":[{"issue_id":"ios-buzz-59e.1","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:44.065793-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":1,"issue_id":"ios-buzz-59e.1","author":"Brian Charbonneau","text":"Prerequisites passed: fork main is authoritative and has no develop branch or GitFlow guard; GitHub keyring and required Notion page are accessible; two aggregate peer-presence checks 63 seconds apart reported both online; Flutter 3.41.7, Xcode 26.6, Beads, just, and cached mobile dependencies are available; iOS 26.5 simulators are installed. Physical iOS 27 testing is explicitly deferred and the unbacked phone remains untouched.","created_at":"2026-08-10T04:46:21Z"}]} +{"id":"ios-buzz-59e.11","title":"Isolate Desktop relay admission-gate tests","description":"A relay error-message test arms the process-wide Desktop admission gate with a capped 300-second window without acquiring the shared test serialization mutex or resetting the gate. Under parallel cargo test execution it contaminates relay_admission tests, producing 300.001 seconds where 5 seconds is required. Deliver a separate TDD, test-only remediation; do not change production admission semantics, reduce hints, serialize the entire suite, skip tests, or use retries to manufacture green.","acceptance_criteria":"Deterministic Red reproduces the cross-module shared-state leak. The smallest fix makes every test that mutates the global admission gate participate in the shared isolation contract and clean up its state. Targeted race coverage, repeated parallel execution, desktop-tauri tests, and full just ci pass. Deliver through its own main-target PR and verify post-merge main.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T17:13:40.815578-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T19:56:07.927875-07:00","closed_at":"2026-08-10T19:56:05.728948-07:00","dependencies":[{"issue_id":"ios-buzz-59e.11","depends_on_id":"ios-buzz-59e.12","type":"blocks","created_at":"2026-08-10T19:15:05.2623-07:00","created_by":"import"}],"comments":[{"id":27,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"TDD Red captured on 2026-08-10: targeted Rust test exits 101 because relay.rs and relay_admission.rs reference the intentionally missing isolate_test_gate helper. This deterministically defines the shared lock plus cleanup contract before implementation; no production admission code changed.","created_at":"2026-08-11T00:22:47Z"},{"id":28,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"Green evidence: new RAII test-isolation boundary resets the process-wide admission gate before and after every participating test, including unwind/drop cleanup. Targeted guard and 429 regression tests pass; relay library subset passed 110 tests with 5 expected ignores on five consecutive runs; desktop-tauri fmt and clippy pass; desktop-tauri test passed 2,115 tests with 15 expected ignores plus 3 diagnostic tests; full just ci exited 0, including mobile 1,098 tests with 1 expected skip. Final two-file diff is whitespace-clean, has no untracked files, and the changed-diff secret scan found no credential patterns.","created_at":"2026-08-11T00:42:35.414314Z"},{"id":29,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"Delivery blocker after commit cc4c5a6c and safe branch push: opening the standalone PR would trigger GitHub-hosted Windows Rust and macOS Desktop Build jobs because desktop/src-tauri paths set desktop-rust=true. Repository runner inventory reports zero self-hosted runners. Per repository policy, no PR was opened, no CI suppression or weakened check was attempted, and the pushed branch is preserved for a compliant runner path.","created_at":"2026-08-11T00:44:37.361173Z"},{"id":34,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"UNBLOCKED: the authoritative Ruler policy now permits standard GitHub-hosted Linux, macOS, and Windows runners for verified public repositories. myContext commit bc275f1 is on origin/main; isolated Buzz propagation passed; live metadata reconfirmed BrianInAz/buzz is PUBLIC, isFork=true, parent block/buzz. Standalone PR delivery may resume.","created_at":"2026-08-11T02:19:15.454954Z"},{"id":35,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"PR #28 opened ready against main at https://github.com/BrianInAz/buzz/pull/28 with exact head cc4c5a6c. CI run 31452077140 correctly selected Desktop, Windows Rust, and macOS Desktop Build under the new verified-public-repository policy; unrelated mobile/web/server jobs are skipped by path routing. Monitoring full matrix before merge.","created_at":"2026-08-11T02:21:04.358589Z"},{"id":36,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"CI GREEN: PR #28 completed with 16 successful checks, 7 intentional path-routing skips, 0 failures, and 0 cancellations. Standard public macOS passed in 6m09s; Windows Rust passed in 12m48s; Desktop Core and all E2E/smoke shards passed. Ready for squash merge.","created_at":"2026-08-11T02:33:59.40782Z"},{"id":37,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"MERGED: PR #28 squash-merged to main as 4deea1a0d7fc9eb4ec95a5999487e51a96a47483. Fetch verified origin/main contains the exact two-file test-only diff. Post-merge main CI run 31452775282 is queued/running; final closure and guarded branch cleanup wait for main validation.","created_at":"2026-08-11T02:34:46.716138Z"},{"id":38,"issue_id":"ios-buzz-59e.11","author":"Brian Charbonneau","text":"POST-MERGE GREEN: main CI run 31452775282 completed at exact merged SHA 4deea1a0d7fc9eb4ec95a5999487e51a96a47483 with 23 successful jobs and no failures, including standard public macOS and Windows. Helm run 31452775281 also passed. The separate Sprig run 31452775336 failed only at its already-tracked missing sprig-latest bootstrap defect (ios-buzz-59e.9). Guarded cleanup verified PR #28 MERGED, exact local/remote branch tip cc4c5a6c, zero open PRs, and a clean worktree; the worktree and local/remote isolation branches were removed and absence verified.","created_at":"2026-08-11T02:56:05.56118Z"}]} +{"id":"ios-buzz-59e.12","title":"Correct global hosted-runner policy for the public Buzz fork","description":"Update the authoritative BjzyLabs/myContext Ruler source so standard GitHub-hosted Linux, macOS, and Windows runners are permitted for verified public repositories, while larger runners and unverified/private repositories remain outside standing authorization. Record BrianInAz/buzz as the sole currently approved BrianInAz public repository and verify it remains a public fork of block/buzz before triggering.","acceptance_criteria":"A pre-edit contract fails; the master AGENTS.md rule contains the scoped public-repository allowance and safe verification requirements; validation and diff audit pass; the DCO commit is pushed to myContext origin/main; ruler-sync-global regenerates an isolated Buzz checkout with the exact wording; no user-owned Buzz changes are overwritten.","status":"closed","priority":0,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T19:14:55.606975-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T19:19:15.230984-07:00","closed_at":"2026-08-10T19:19:13.417141-07:00","dependencies":[{"issue_id":"ios-buzz-59e.12","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-10T19:14:57.431958-07:00","created_by":"import"}],"comments":[{"id":30,"issue_id":"ios-buzz-59e.12","author":"Brian Charbonneau","text":"RED: authoritative myContext AGENTS.md contains none of the required public-runner contract markers: BrianInAz/buzz identity, free-and-unlimited standard public runner scope, or larger-runner exclusion. The pre-edit contract exited 1 as expected.","created_at":"2026-08-11T02:15:23.065258Z"},{"id":31,"issue_id":"ios-buzz-59e.12","author":"Brian Charbonneau","text":"GREEN master evidence: the authoritative rule now permits standard Linux, macOS, and Windows hosted runners only for verified public repositories; excludes larger runner products; limits the current BrianInAz allowlist to BrianInAz/buzz; and requires exact PUBLIC/fork/parent metadata before macOS or Windows jobs. Live GitHub metadata matched PUBLIC, isFork=true, parent block/buzz. git diff --check passed and only myContext AGENTS.md is modified.","created_at":"2026-08-11T02:16:22.336312Z"},{"id":32,"issue_id":"ios-buzz-59e.12","author":"Brian Charbonneau","text":"DURABLE: authoritative BjzyLabs/myContext main now contains DCO-signed commit bc275f1da05fb1d21907f85556d7f75decfcd008. Remote refs/heads/main and GitHub API both resolve to the exact commit; the myContext working tree is clean.","created_at":"2026-08-11T02:17:47.580402Z"},{"id":33,"issue_id":"ios-buzz-59e.12","author":"Brian Charbonneau","text":"PROPAGATION GREEN: ruler-sync-global --quiet --yes completed in disposable detached Buzz worktree /private/tmp/ios-buzz-ruler-runner-policy at origin/main 594d8cc5. Both .ruler/00_global.md and generated AGENTS.md contain the exact hosted-runner scope and BrianInAz/buzz guards. The dirty primary feature/ios-connection-indicator status remained byte-for-byte equivalent to its pre-existing classification; no user-owned file was touched.","created_at":"2026-08-11T02:18:40.635531Z"}]} +{"id":"ios-buzz-59e.13","title":"Atomically publish staged Desktop providers before execution","description":"PR #29 CI exposed a pre-existing Linux Desktop provider-staging defect: managed_agents::backend::tests::provider_deploy_negotiates_and_deploys_the_same_staged_bytes failed with ETXTBSY while spawning the staged provider. stage_provider currently creates and writes directly at the final executable pathname, then closes and immediately executes it. Diagnose and remediate separately with TDD; do not add sleeps, retries, ignored tests, platform skips, broad serialization, or changes to Sprig publication logic.","acceptance_criteria":"A deterministic Red contract proves the final executable pathname is published only after all writable handles are closed. The smallest production-safe fix writes and syncs under a private non-executable staging name, applies final permissions, closes the writer, atomically publishes the executable pathname, and preserves the existing immutable-byte and Windows sharing contracts. Focused provider tests pass repeatedly on Linux and locally; Desktop Tauri and full just ci pass; delivery uses its own DCO-signed main-target PR, post-merge main validation, Beads evidence, and guarded cleanup. PR #29 remains unmerged until this dependency is green.","notes":"Authorized and in progress. PR #29 CI job 93664535019 is the product-level Red; the deterministic unit contract now requires separate candidate write and atomic executable publication helpers.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T20:22:18.937063-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-11T00:45:43.414437-07:00","closed_at":"2026-08-11T00:45:43.414437-07:00","close_reason":"Provider staging remediation merged with full green CI.","dependencies":[{"issue_id":"ios-buzz-59e.13","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-10T20:22:21.058731-07:00","created_by":"import"},{"issue_id":"ios-buzz-59e.13","depends_on_id":"ios-buzz-59e.15","type":"blocks","created_at":"2026-08-10T21:11:55.009512-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":44,"issue_id":"ios-buzz-59e.13","author":"Brian Charbonneau","text":"TDD GREEN: candidate publication contract passes; all 8 provider-focused tests pass; the exact former ETXTBSY test passes 20 consecutive runs; cargo fmt and clippy -D warnings pass; full Desktop Tauri suite passes 2,116 tests with 15 expected ignores plus 3 diagnostics; full repository just ci passes, including 1,098 mobile tests with 1 expected skip. Final diff is limited to backend.rs and backend_tests.rs, diff check and changed-diff secret review are clean. Corrected the dependency direction so this provider remediation blocks Sprig task ios-buzz-59e.9.","created_at":"2026-08-11T03:53:45Z"},{"id":54,"issue_id":"ios-buzz-59e.13","author":"Brian Charbonneau","text":"PR #30 merged into main after refreshed full CI (Linux, macOS, Windows, Desktop Core, and all E2E shards) passed. GitHub merge commit is recorded on PR #30.","created_at":"2026-08-11T07:45:43Z"},{"id":42,"issue_id":"ios-buzz-59e.13","author":"Brian Charbonneau","text":"AUTHORIZED by the user. Execution boundary: isolated branch fix/desktop-provider-staging-atomic from refreshed origin/main; deterministic TDD Red; smallest atomic-publication production fix; targeted repeated Linux-capable CI plus full local/hosted validation; own main PR, squash merge, post-merge main, guarded cleanup; then immediately return to PR #29.","created_at":"2026-08-11T03:28:37.391649Z"},{"id":43,"issue_id":"ios-buzz-59e.13","author":"Brian Charbonneau","text":"TDD RED: after the repo-native ignored sidecar prerequisite, the targeted Rust test fails to compile with E0425 because copy_provider_to_candidate and publish_provider_candidate do not exist. The test contract proves the final executable path stays absent while its candidate writer is open, then appears only when publication consumes that writer. Existing PR #29 Linux CI remains the runtime Red (ETXTBSY).","created_at":"2026-08-11T03:34:05.129673Z"}]} +{"id":"ios-buzz-59e.15","title":"Preserve Desktop thread anchor across focus/split mode changes","description":"PR #30 CI exposed a pre-existing Desktop UI defect in thread-focus-mode.spec.ts: focus and split preserve reading context and interaction ownership. After the thread body is scrolled and its top visible message is captured, switching from the focus drawer to split mode leaves that same row mounted but entirely outside the viewport. The test failed the initial attempt and both configured retries. The provider PR changes only Rust staging files; the entire Desktop UI and test source are byte-identical to origin/main. Diagnose and remediate separately with TDD; do not weaken viewport assertions, add waits or retries, skip the test, change sharding, or mix UI changes into the provider commit.","acceptance_criteria":"A deterministic Red contract proves the captured top-visible message leaves the viewport across a focus-to-split layout transition. The smallest production fix restores the same stable message anchor after layout/presentation changes while preserving focus, inert state, tooltip dismissal, and Escape ownership. Targeted test, complete smoke shard, Desktop checks, full just ci, and its own DCO-signed PR/main promotion all pass before PR #30 is refreshed.","notes":"Discovered in PR #30 run 31456827819, job 93672304430. Awaiting explicit user authorization for this separate TDD remediation. No code changes started.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T21:11:28.089585-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-11T00:29:49.262772-07:00","closed_at":"2026-08-11T00:29:49.262772-07:00","close_reason":"Upstream corrected focus-mode test is merged and verified.","comments":[{"id":45,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"AUTHORIZED by the user. Execution boundary: isolated fix/desktop-thread-mode-anchor branch from refreshed origin/main; deterministic TDD Red; smallest stable-anchor restoration fix; targeted smoke, full local validation, own DCO-signed main PR, merge/post-merge validation and guarded cleanup; then refresh PR #30 and return to PR #29. Forbidden: waits, sleeps, retries, weakened viewport assertions, test skips, sharding changes, or mixing UI work into provider/Sprig commits.","created_at":"2026-08-11T04:37:15Z"},{"id":46,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"TDD Red captured on exact origin/main 4deea1a0: new lifecycle regression test proves a retired layout target moves from viewport top 200 to 500 after later passive reflow instead of retaining its reading offset. Full Desktop unit run: 3,954 pass, exactly 1 expected new failure; no skips or weakened assertions. Hermit reused cached dependencies with no downloads.","created_at":"2026-08-11T04:44:43Z"},{"id":47,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"TDD Green: 18/18 focused anchor lifecycle/observer tests pass, including delayed programmatic scroll retention, changed-position reader ownership, retired-layout reflow, staged replies, and short-thread deferred growth. Complete Desktop units pass 3,959/3,959; typecheck and check pass (only pre-existing non-failing warnings). Full smoke shard reached and passed the repaired thread case, then ended 195/197 on the same unrelated relay-seam and video event failures present on the untouched baseline. A later exact run exposed changed-position ownership, producing a new deterministic Red and the minimal row-plus-viewport movement guard; the post-fix exact run was blocked before assertions because the pre-existing mock-provider seam did not initialize in either case. Next gate is stacked integration with provider-staging PR #30; no retries, waits, assertion changes, skips, or mixed source commit.","created_at":"2026-08-11T06:13:24Z"},{"id":48,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"STOPPED per user direction after the next tested approach proved non-final. Instrumented exact E2E showed the test-captured top row and the later mode-toggle-captured row diverge before the click: an unowned physical-floor scroll remained bottom-follow while deferred layout advanced the visible row. A deterministic tall-newest-row floor-scroll test was added in the uncommitted experiment and went Green, but the broad intent-classification attempt regressed 9 existing lifecycle/observer contracts, so it was rejected and not retained. All temporary diagnostics, the failed uncommitted experiment, and the disposable integration worktree were removed. Isolated unpushed commit c97dbfca remains preserved on fix/desktop-thread-mode-anchor; no PR was opened, Bead remains IN_PROGRESS, and PRs #30/#29 were not advanced.","created_at":"2026-08-11T06:30:47Z"},{"id":49,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"Repository comparison proved the production anchor hooks are byte-identical to current upstream. The local production patch c97dbfca was not pushed and targets the wrong layer. User directed us to discard it, import upstream test-only correction 9213090f, and prove unchanged production code before any promotion.","created_at":"2026-08-11T06:42:47Z"},{"id":50,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"Discarded unpushed production patch c97dbfca by resetting the isolated branch to origin/main. Cherry-picked upstream test-only correction 9213090f as local DCO-signed commit 178f3c78. Desktop E2E build passed. Corrected focus/split acceptance passed 20 consecutive local macOS Playwright runs against unchanged production source. Initial sandbox attempts could not launch Chromium due macOS Mach-port denial; the same test completed outside the sandbox. Conclusion: stale fork test, not a fork production regression.","created_at":"2026-08-11T06:49:10Z"},{"id":51,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"Promotion resumed by user authorization. Publishing the exact upstream test-only correction before refreshing PR #30, then PR #29. Gate: each PR must receive green checks on its refreshed head before merge.","created_at":"2026-08-11T07:15:17Z"},{"id":52,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"Published ready PR #31 at exact branch head 178f3c78. Awaiting the required GitHub checks before merge; no PR #30 or #29 mutation has occurred.","created_at":"2026-08-11T07:15:43Z"},{"id":53,"issue_id":"ios-buzz-59e.15","author":"Brian Charbonneau","text":"PR #31 merged into main as ad9e7c31d5bee85c719acf474c4a49dffce0b487 after all applicable CI checks passed; this imports upstream test correction 9213090f and unblocks provider PR #30.","created_at":"2026-08-11T07:29:49Z"}]} +{"id":"ios-buzz-59e.16","title":"Fix Desktop stale channel autocomplete Enter interception","description":"Fix the pre-existing upstream race where a pending channel-autocomplete debounce can consume Enter and reinsert a stale channel chip after the editor is cleared. Use deterministic TDD, preserve composer parity, and promote through a hotfix PR only after local and hosted checks pass.","acceptance_criteria":"Pure resolver tests cover inactive, changed-query, no-match, fresh-offset, and cancellation behavior; deterministic empty-edit E2E proves immediate Enter opens the delete dialog without reinserting a stale channel; full validation passes; hotfix PR merges to main; post-merge main CI is green.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-11T02:55:48.676353-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-11T14:10:55.129628-07:00","closed_at":"2026-08-11T14:10:55.129628-07:00","close_reason":"Merged PR #33 as f73b2bdd5; complete post-merge main CI, Sprig, and Helm passed; guarded branch and worktree cleanup verified.","dependencies":[{"issue_id":"ios-buzz-59e.16","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-11T02:55:48.6771-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":57,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Claimed for isolated TDD remediation. Delivery boundary: DCO hotfix commit, PR to main, squash merge after green checks, post-merge main CI proof, guarded cleanup, then resume PR #25 only if main remains fully green.","created_at":"2026-08-11T09:55:49Z"},{"id":58,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Red evidence at origin/main ce5acf44: focused unit test fails because the new resolver is absent; deterministic Playwright run (workers=1, retries=0) fails both empty-edit cases while the non-empty edit passes. Both failure snapshots retain edit mode with a reinserted #general paragraph and no alertdialog after 10 seconds. This reproduces the intended production race without sleeps or retrying.","created_at":"2026-08-11T10:02:23Z"},{"id":59,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Green evidence: pure debounce resolver unit suite passed 5/5; deterministic empty-edit browser spec passed 3/3; repeat-each=10 stress proof passed 30/30. Fast clear-and-Enter now opens deletion confirmation without reinserting a stale channel chip, and non-empty edit behavior remains intact.","created_at":"2026-08-11T10:06:54Z"},{"id":60,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Validation blocker: targeted unit, Desktop suite, build, focused E2E, and 10x stress E2E are Green. The full just ci gate reached Cargo clippy and revealed that the local Cargo cache was incomplete; Cargo began fetching public locked dependencies, so the run was interrupted to honor the offline-only dependency boundary. No lockfile or tracked generated file changed. Hotfix remains uncommitted and unpromoted pending explicit direction.","created_at":"2026-08-11T10:12:35Z"},{"id":61,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Offline CI rerun result: root workspace cargo fmt/clippy and Desktop checks passed. The gate stopped at desktop-tauri-clippy because locked crate audioadapter-buffers 3.0.0 is absent from the local Cargo cache. No network fetch was permitted, no lockfile changed, and no commit/PR/promotion was attempted. Awaiting explicit dependency-fetch authority or a pre-populated cache.","created_at":"2026-08-11T10:20:40Z"},{"id":62,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Authorized locked Cargo fetch completed successfully, including audioadapter-buffers 3.0.0. Cargo.lock, desktop/src-tauri/Cargo.lock, mobile/pubspec.lock, and pnpm-lock.yaml remain unchanged. Full local validation is Green: focused resolver 5/5, deterministic E2E 3/3, repeat-each=10 30/30, Desktop helper suite 3959/3959, desktop-check, desktop-test/build, mobile checks/tests, Tauri checks/tests, web checks/build, root suites, and complete just ci with CARGO_NET_OFFLINE=true. git diff --check and scoped secret scan are clean.","created_at":"2026-08-11T20:34:24Z"},{"id":63,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"Published DCO commit da4b5cb47 and opened hotfix PR #33 to main: https://github.com/BrianInAz/buzz/pull/33. PR includes deterministic Red/Green evidence and complete local CI proof. An unrelated upstream annotated tag was automatically followed by the first push due global push.followTags; it was immediately removed from origin after verification, leaving only the intended hotfix branch. Subsequent pushes will set push.followTags=false per command.","created_at":"2026-08-11T20:39:44Z"},{"id":64,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"PR #33 pre-merge gate passed at head da4b5cb47: GitHub reports CLEAN and MERGEABLE with 14 successful checks, 9 expected path-filter skips, and zero failures. Desktop Core, macOS build, all four smoke shards, both integration shards, release-candidate contract, relay smoke, and guards passed. Proceeding with planned squash merge.","created_at":"2026-08-11T20:52:39Z"},{"id":65,"issue_id":"ios-buzz-59e.16","author":"Brian Charbonneau","text":"PR #33 squash-merged to main as f73b2bdd5. Exact-SHA post-merge evidence: CI run 31535160441 succeeded with 23 successful jobs; Sprig run 31535160539 succeeded; Helm run 31535160483 succeeded; Docker run 31535160432 was expected path-skipped. The hotfix worktree and exact local/remote task branches were guardedly removed and pruned; the accidentally followed upstream tag remains absent from origin. Dirty feature/ios-connection-indicator checkout and clean mobile follow-mode worktree were preserved.","created_at":"2026-08-11T21:10:49Z"}]} +{"id":"ios-buzz-59e.2","title":"Write failing iOS presence hydration regression tests","description":"Add the approved Red tests for snapshot hydration, trust boundaries, failures, races, deduplication, reconnection, and refresh.","status":"closed","priority":0,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:44.251712-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-09T21:51:27.866662-07:00","closed_at":"2026-08-09T21:51:27.866662-07:00","close_reason":"Closed","dependencies":[{"issue_id":"ios-buzz-59e.2","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:44.252989-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.2","depends_on_id":"ios-buzz-59e.1","type":"blocks","created_at":"2026-08-09T21:44:45.171577-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":2,"issue_id":"ios-buzz-59e.2","author":"Brian Charbonneau","text":"Red evidence: targeted Flutter test exited 1 with nine expected failures for absent query hydration, snapshot trust/defaults, race protection, normalization, reconnect refresh, periodic refresh, and subscription retry; all six pre-existing WebSocket behavior tests passed.","created_at":"2026-08-10T04:51:27Z"}]} +{"id":"ios-buzz-59e.3","title":"Implement query-backed hydration and race-safe live merging","description":"Implement the minimum supported provider fix without changing widgets, relay, CLI, Desktop, or Hermes.","status":"closed","priority":0,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:44.44041-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-09T21:56:45.637961-07:00","closed_at":"2026-08-09T21:56:45.637961-07:00","close_reason":"Closed","dependencies":[{"issue_id":"ios-buzz-59e.3","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:44.441984-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.3","depends_on_id":"ios-buzz-59e.2","type":"blocks","created_at":"2026-08-09T21:44:45.333329-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":3,"issue_id":"ios-buzz-59e.3","author":"Brian Charbonneau","text":"Implemented provider-only hydration: 50 ms normalized batching; bounded author-scoped kind 20001 query filters; trusted snapshot p-tag parsing with omission-to-offline only after success; per-key query/live revision guards; 60-second connected refresh; disconnect clearing and immediate reconnect hydration; capped subscription setup retry; disposal of all timers and subscriptions. Targeted 15-test suite and just mobile-check pass.","created_at":"2026-08-10T04:56:45Z"}]} +{"id":"ios-buzz-59e.4","title":"Run mobile and iOS simulator acceptance","description":"Run full validation and iOS 26.5 simulator acceptance. Physical iOS 27 testing is explicitly deferred because the phone is unbacked and must remain untouched.","status":"closed","priority":0,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:44.633559-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T00:50:37.896698-07:00","closed_at":"2026-08-10T00:50:37.896698-07:00","close_reason":"Simulator acceptance complete: 5/5 cold launches and 5/5 reconnects passed; physical iPhone deferred by user.","dependencies":[{"issue_id":"ios-buzz-59e.4","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:44.634758-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.4","depends_on_id":"ios-buzz-59e.3","type":"blocks","created_at":"2026-08-09T21:44:45.496239-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.4","depends_on_id":"ios-buzz-59e.7","type":"blocks","created_at":"2026-08-09T22:26:34.170991-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":4,"issue_id":"ios-buzz-59e.4","author":"Brian Charbonneau","text":"BLOCKED: just mobile-test passed 1,106 tests but failed the pre-existing ChannelDetailPage test keeps follow mode off while a tall newest message stays visible. The exact named test fails identically in isolation on this branch and on untouched origin/main, proving it is a baseline defect unrelated to presence hydration. Approved scope forbids unrelated fixes and requires zero failures, so repository-wide CI, simulator acceptance, docs publication, and merge are halted pending explicit scope direction.","created_at":"2026-08-10T04:59:34Z"},{"id":8,"issue_id":"ios-buzz-59e.4","author":"Brian Charbonneau","text":"Baseline blocker ios-buzz-59e.7 is remediated and full mobile tests are green; simulator acceptance may resume.","created_at":"2026-08-10T06:19:17Z"},{"id":9,"issue_id":"ios-buzz-59e.4","author":"Brian Charbonneau","text":"Simulator pairing blocker proven: clean iOS 26.5 simulator rejects the Buzz relay certificate with 'This Connection Is Not Private'. Two fresh NIP-AB pairings matched SAS on both sides but timed out before identity import because the simulator does not trust the existing Home Lab CA. Awaiting explicit approval to add that CA only to isolated simulator 524E8953-A81A-4FAD-8545-8F66CB7B35C3 using simctl; physical iPhone remains untouched and certificate warnings will not be bypassed.","created_at":"2026-08-10T07:38:23Z"},{"id":10,"issue_id":"ios-buzz-59e.4","author":"Brian Charbonneau","text":"Simulator acceptance passed on iPhone 17 Pro, iOS 26.5, UDID 524E8953-A81A-4FAD-8545-8F66CB7B35C3, worktree bundle com.buzz.buzzMobile.ios-buzz-presence-hydration. Existing public Home Lab CA was installed only in this simulator after explicit approval; physical iPhone was untouched. Authoritative CLI checks before and after acceptance reported Hermes Dev and Prod online. Five cold launches and five background/foreground cycles each showed both Hermes dots green in screenshots requested at 4.8 seconds (capture completion about 5.08s cold and 5.57s framed UI). Owner dot stayed green, Hermes Dev DM opened and reported Online, and the non-mutating multi-recipient New message flow remained available; full mobile tests cover multi-recipient behavior.","created_at":"2026-08-10T07:50:37Z"},{"id":12,"issue_id":"ios-buzz-59e.4","author":"Brian Charbonneau","text":"Strict timing evidence supersedes the earlier 4.8-second capture note: five fresh cold-launch screenshots completed in 4.282-4.914 seconds and all showed both Hermes dots green. Five fresh foreground-reconnect captures completed in 4.735-4.765 seconds and all showed both dots green. Cold screenshots at about 4.29 seconds showed the normal loading skeleton, establishing that hydration finishes between the loading boundary and the five-second acceptance limit.","created_at":"2026-08-10T07:56:44Z"}]} +{"id":"ios-buzz-59e.5","title":"Publish repository and Notion documentation","description":"Maintain the as-built repository document and publish the linked Notion child page and parent changelog without secrets.","status":"closed","priority":1,"issue_type":"task","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:44.819286-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T01:02:08.809857-07:00","closed_at":"2026-08-10T01:02:08.809857-07:00","close_reason":"Closed","dependencies":[{"issue_id":"ios-buzz-59e.5","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:44.820663-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.5","depends_on_id":"ios-buzz-59e.4","type":"blocks","created_at":"2026-08-09T21:44:45.658359-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":11,"issue_id":"ios-buzz-59e.5","author":"Brian Charbonneau","text":"Simulator acceptance is green. Drafting repo as-built documentation and evidence assets, then publishing the linked Notion child page and parent changelog. Documentation will omit pairing codes, keys, pubkeys, and private device identifiers.","created_at":"2026-08-10T07:53:01Z"},{"id":13,"issue_id":"ios-buzz-59e.5","author":"Brian Charbonneau","text":"Repository as-built record and before/after assets are present. Notion child remediation page was created and fetched under the Buzz parent; the parent Changelog now links it and distinguishes relay connectivity from peer presence. Final PR and merged SHA updates remain part of delivery task .6. No private keys, pubkeys, pairing codes, or device identifiers were added to the docs.","created_at":"2026-08-10T08:02:06Z"}]} +{"id":"ios-buzz-59e.6","title":"Deliver PR, merge, and clean task branch","description":"Commit with DCO, push, open and merge the main-target PR after green simulator acceptance, verify origin/main, and guardedly remove the task branch and temporary worktrees.","status":"in_progress","priority":0,"issue_type":"task","owner":"github@briancharbonneau.com","created_at":"2026-08-09T21:44:45.007292-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T01:02:10.937825-07:00","dependencies":[{"issue_id":"ios-buzz-59e.6","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T21:44:45.008564-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.6","depends_on_id":"ios-buzz-59e.5","type":"blocks","created_at":"2026-08-09T21:44:45.820826-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.6","depends_on_id":"ios-buzz-59e.8","type":"blocks","created_at":"2026-08-10T01:35:36.428508-07:00","created_by":"Brian Charbonneau"},{"issue_id":"ios-buzz-59e.6","depends_on_id":"ios-buzz-59e.9","type":"blocks","created_at":"2026-08-10T12:55:19.70238-07:00","created_by":"import"},{"issue_id":"ios-buzz-59e.6","depends_on_id":"ios-buzz-59e.16","type":"blocks","created_at":"2026-08-11T02:55:48.914416-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":14,"issue_id":"ios-buzz-59e.6","author":"Brian Charbonneau","text":"All repo-native validation is green, including the full Hermit-backed just ci gate. Simulator acceptance and documentation gates are complete. Delivery is proceeding through commit, push, and a main-target PR. Agent-triggered merge is intentionally blocked by repository instructions because a push to main would start prohibited GitHub-hosted macOS and Windows jobs; the PR will be handed off ready for a human merge, after which final merged-build proof, documentation finalization, Beads closure, and guarded cleanup remain.","created_at":"2026-08-10T08:26:37Z"},{"id":15,"issue_id":"ios-buzz-59e.6","author":"Brian Charbonneau","text":"Source committed with DCO and pushed on fix/ios-peer-presence-hydration. Pull request #25 targets main and includes simulator evidence plus Beads reference. Merge remains pending human action because an agent-triggered push to main would start repository-prohibited GitHub-hosted macOS and Windows jobs.","created_at":"2026-08-10T08:29:42Z"},{"id":16,"issue_id":"ios-buzz-59e.6","author":"Brian Charbonneau","text":"PR workflow run 31370360522 was cancelled immediately after the path detector incorrectly classified mobile/docs/Beads-only changes as Desktop and started the hosted macOS job. Log evidence shows the standalone !desktop/src-tauri/** negative glob matched every changed file outside that directory. Created blocking bug ios-buzz-59e.8 for an explicitly separate TDD remediation. PR #25 is draft; no rerun, workaround, merge, or branch cleanup was attempted.","created_at":"2026-08-10T08:35:17Z"},{"id":66,"issue_id":"ios-buzz-59e.6","author":"Brian Charbonneau","text":"Promotion prerequisites are now resolved. PR #33 merged as f73b2bdd5d95 after deterministic TDD for the pre-existing Desktop stale channel-autocomplete race; exact-SHA CI run 31535160441, Sprig run 31535160539, and Helm run 31535160483 are green. PR #25 was refreshed from that verified main through signed-off merge e3b5e4cd9c92 with no conflicts. Fresh focused provider tests passed 15/15, mobile-check passed, mobile-test passed 1,107 with one intentional skip, and the complete offline repository just ci gate passed. Lockfiles are unchanged and final scope/secret/diff checks are clean. Next gate is hosted PR #25 validation; any failure stops promotion.","created_at":"2026-08-11T21:26:41Z"}]} +{"id":"ios-buzz-59e.7","title":"Remediate pre-existing ChannelDetailPage tall-message follow-mode regression","description":"Use TDD to classify the origin/main failure as a production scroll defect or stale widget-tree assertion, then implement the smallest correct fix and restore the full mobile test gate without weakening coverage.","acceptance_criteria":"A failing geometry/state-based regression test proves the intended user-visible behavior, the smallest remediation passes it, the original targeted case is green, and the full mobile suite no longer fails at this baseline case.","notes":"Reopened for the explicitly authorized separate PR and promotion boundary. Current origin/main at ef72743fb reproduces the failure: the newly appended message becomes visible while follow mode is off.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-09T22:26:25.838226-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-10T13:41:06.410578-07:00","closed_at":"2026-08-10T13:41:06.410578-07:00","close_reason":"Separate TDD remediation promoted via PR #27 as main commit 594d8cc5; targeted, 61-test file, mobile-check, full mobile suite, and PR CI all green.","dependencies":[{"issue_id":"ios-buzz-59e.7","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-09T22:26:25.839667-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":5,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"Authorized by user as a separate TDD remediation. Scope is limited to the pre-existing ChannelDetailPage tall-newest-message follow-mode failure; no presence-provider behavior may be altered.","created_at":"2026-08-10T05:26:34Z"},{"id":6,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"Red evidence: the original origin/main assertion was stale, but geometry-based replacement exposed a real product defect. After the live row is inserted while follow mode is off, the tall prior-newest message moves from y=142 to y=44 (98 points), matching the inserted row height. Production must preserve the visible anchor.","created_at":"2026-08-10T05:30:08Z"},{"id":7,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"Green evidence: geometry regression, full 61-test ChannelDetailPage suite, mobile formatting, Flutter analysis, file-size policy, and full mobile suite pass. Full mobile result: 1107 passed, 1 skipped, 0 failed.","created_at":"2026-08-10T06:19:16Z"},{"id":23,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"RED: origin/main ef72743fb with the strengthened geometry regression fails deterministically. The visible tall-message top moves from 142 to 44 pixels after a live append (98-pixel shift), which can make the new message visible and incorrectly restore follow behavior.","created_at":"2026-08-10T20:22:49Z"},{"id":24,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"GREEN: stable-ID anchor restoration passes the strengthened targeted regression; the complete ChannelDetailPage file is 61/61; mobile-check is green; and the full mobile suite is 1,098 passed, 1 intentional skip, 0 failed. The prior repository-wide run on the same ef72743fb base had all non-mobile gates green and failed only this now-corrected test.","created_at":"2026-08-10T20:25:19Z"},{"id":25,"issue_id":"ios-buzz-59e.7","author":"Brian Charbonneau","text":"PROMOTED: PR #27 merged to main as 594d8cc5ce0c314b9498dda4c99847afef95cd60 after all PR checks passed. Mobile was the only substantive CI job; hosted macOS and Windows jobs were skipped. Post-merge main CI run 31430183471 is underway.","created_at":"2026-08-10T20:40:59Z"}]} +{"id":"ios-buzz-59e.8","title":"Fix CI Desktop path filter overmatching mobile-only changes","description":"The CI Desktop filter includes desktop/** plus !desktop/src-tauri/** under predicate-quantifier some. The negative pattern independently matches every changed file outside desktop/src-tauri, so a mobile/docs/Beads-only pull request reports desktop=true and starts the macOS Desktop job. Workflow run 31370360522 was cancelled immediately when this was observed. Remediate separately with TDD; do not weaken, skip, manually spoof, or bypass required checks.","acceptance_criteria":"Contract tests first prove mobile/docs/Beads-only changes set mobile=true and desktop=false; Desktop source changes still set desktop=true; desktop/src-tauri changes remain scoped correctly. The presence PR can then obtain its normal green mobile check without starting hosted macOS or Windows jobs.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T01:33:36.17167-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-11T02:55:34.795316-07:00","closed_at":"2026-08-11T02:55:34.795316-07:00","close_reason":"Closed","dependencies":[{"issue_id":"ios-buzz-59e.8","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-10T01:33:36.172608-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":17,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"User explicitly authorized the separate TDD remediation. Scope is limited to correcting CI path classification and adding contract coverage; no check suppression, workflow skipping, manual status spoofing, or unrelated CI changes are allowed.","created_at":"2026-08-10T14:40:04Z"},{"id":18,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"Red evidence on untouched origin/main: all 3 new path-routing contracts fail. A mobile file incorrectly selects desktop=true, a desktop/src-tauri file incorrectly selects both Desktop filters, and the standalone negative rule is detected under predicate-quantifier some semantics.","created_at":"2026-08-10T14:45:18Z"},{"id":19,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"Green evidence: 3/3 new path-routing contracts and 6/6 existing file-size contracts pass; every existing Detect Changed Paths contract command passes; workflow YAML parses; Biome and diff checks pass; and the replacement positive extglob matches mobile/docs/Beads=false, Desktop frontend=true, and desktop/src-tauri=false while the existing desktop-rust filter remains true. The default standalone yamllint profile still reports only the extensive pre-existing ci.yml baseline and no violation on changed lines.","created_at":"2026-08-10T14:49:36Z"},{"id":20,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"Implementation and local validation are complete at signed-off commit 3887b68c on fix/ci-desktop-path-filter. GitHub rejected the safe branch-only push because the existing OAuth credential lacks workflow scope for updating .github/workflows/ci.yml. No alternate credential, authentication flow, PR, workflow run, or workaround was attempted. Human must refresh the existing GitHub CLI authorization with workflow permission before delivery can resume.","created_at":"2026-08-10T14:50:33Z"},{"id":21,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"Existing GitHub CLI authorization now includes workflow scope. Commit 3887b68c was pushed to remote branch fix/ci-desktop-path-filter without opening a PR. GitHub verification shows no PR and no workflow run for the branch, so no hosted macOS or Windows runner was started. Human PR creation and merge are now required before presence PR #25 can be refreshed.","created_at":"2026-08-10T19:09:39Z"},{"id":22,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"PR #26 completed 23 successful checks with one intentional skip and zero failures, then squash-merged to main as ef72743f. The post-merge main CI, Sprig, and Helm promotion workflows were started; Helm is green and the remaining promotion checks are being monitored before this blocker is closed.","created_at":"2026-08-10T19:47:47.965446Z"},{"id":56,"issue_id":"ios-buzz-59e.8","author":"Brian Charbonneau","text":"Reconciled complete: PR #26 merged to main as ef72743fb63c3fe3c6d1eab0bd4930c7c11bed85 after the full PR CI matrix passed, including path-filter contract coverage. The later ce5acf44 main failure is a separate Desktop channel-autocomplete race tracked by a new blocker.","created_at":"2026-08-11T09:55:34Z"}]} +{"id":"ios-buzz-59e.9","title":"Fix Sprig rolling release bootstrap","description":"The main-push Sprig workflow builds both Linux artifacts successfully, then fails because the rolling publish step assumes GitHub release sprig-latest already exists and invokes only gh release edit. The repository currently has no sprig-latest release, so promotion fails with release not found. Remediate in source with TDD; do not create the release manually, spoof status, skip publication, or weaken the workflow.","acceptance_criteria":"Tests first prove the missing-release path creates sprig-latest as a prerelease with the expected title, notes, target, and assets; the existing-release path edits metadata and replaces assets; command failures remain fatal. The fix is idempotent, locally validated, delivered by its own main-target PR, and its post-merge Sprig workflow completes successfully.","status":"closed","priority":0,"issue_type":"bug","assignee":"Brian Charbonneau","owner":"github@briancharbonneau.com","created_at":"2026-08-10T12:54:55.114565-07:00","created_by":"Brian Charbonneau","updated_at":"2026-08-11T01:07:04.0626-07:00","closed_at":"2026-08-11T01:07:04.0626-07:00","close_reason":"Rolling Sprig release create-or-update implementation merged and publication accepted.","dependencies":[{"issue_id":"ios-buzz-59e.9","depends_on_id":"ios-buzz-59e","type":"parent-child","created_at":"2026-08-10T12:54:56.779666-07:00","created_by":"import"},{"issue_id":"ios-buzz-59e.9","depends_on_id":"ios-buzz-59e.13","type":"blocks","created_at":"2026-08-10T20:53:41.390088-07:00","created_by":"Brian Charbonneau"}],"comments":[{"id":26,"issue_id":"ios-buzz-59e.9","author":"Brian Charbonneau","text":"BLOCKER: after refreshing onto main 594d8cc5, every Sprig/source contract and static check is green, but the mandatory full just ci gate exposed an unrelated Desktop test-isolation race. relay::tests::oversized_hint_is_capped_in_relay_error_message_string arms the process-wide admission gate for 300 seconds without acquiring relay_admission::tests::TEST_SERIAL or resetting the gate; concurrently, concurrent_429_extends_the_window_for_parked_waiters expects 5 seconds and observed 300.001 seconds. Sprig PR/promotion remains halted; no test was skipped or rerun to manufacture green.","created_at":"2026-08-10T20:47:08Z"},{"id":55,"issue_id":"ios-buzz-59e.9","author":"Brian Charbonneau","text":"PR #29 merged into main as ce5acf44ca5a4ef23bcece9f5c2ae34656737ce6 after the full release matrix passed. Post-merge Sprig workflow 31471394148 succeeded; sprig-latest now targets ce5acf44 and contains x86_64/aarch64 Linux archives plus checksums.","created_at":"2026-08-11T08:07:03Z"},{"id":39,"issue_id":"ios-buzz-59e.9","author":"Brian Charbonneau","text":"GREEN after isolation merge: focused Sprig create/update contract, shellcheck, actionlint, and diff check pass. Branch commit 59ab0519 is DCO-signed and rebased onto merged main 4deea1a0. The first full just ci attempt used the ambient Node 20 shell and stopped at pnpm's Node 22.13 prerequisite before Desktop checks; rerunning through the repository's supported Hermit toolchain (Node 24.15.0, pnpm 11.4.0, just 1.46.0) completed successfully. Final suite includes Desktop Rust 2,115 passed/15 expected ignored plus 3 diagnostics and mobile 1,098 passed/1 expected skip. Diff check, clean worktree, DCO, and changed-diff secret scan pass. Live REST metadata reconfirms BrianInAz/buzz is public, a fork, parent block/buzz; standard hosted runner policy applies.","created_at":"2026-08-11T03:01:45.97614Z"},{"id":40,"issue_id":"ios-buzz-59e.9","author":"Brian Charbonneau","text":"PR #29 opened ready against main: https://github.com/BrianInAz/buzz/pull/29 at exact head 59ab0519. Post-merge acceptance remains source-controlled creation of missing sprig-latest; no manual release mutation is authorized or performed.","created_at":"2026-08-11T03:02:23.695806Z"},{"id":41,"issue_id":"ios-buzz-59e.9","author":"Brian Charbonneau","text":"BLOCKED in PR #29 CI run 31454181200: Desktop Core failed only because managed_agents::backend::tests::provider_deploy_negotiates_and_deploys_the_same_staged_bytes could not spawn /tmp/buzz-provider-*/provider with Text file busy (os error 26); 2,119 sibling Desktop tests passed and 15 were expected ignored. This is unrelated to the Sprig diff. No rerun, sleep, retry, skip, or workflow narrowing was used. New P0 dependency ios-buzz-59e.13 tracks a separate TDD remediation that atomically publishes the staged executable only after closing the writer. PR #29 remains open and unmerged.","created_at":"2026-08-11T03:23:01.524114Z"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 00000000000..f581edc0de5 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} diff --git a/docs/assets/screenshots/ios-presence-after-hydration.png b/docs/assets/screenshots/ios-presence-after-hydration.png new file mode 100644 index 00000000000..85f72453f47 Binary files /dev/null and b/docs/assets/screenshots/ios-presence-after-hydration.png differ diff --git a/docs/assets/screenshots/ios-presence-before-offline.png b/docs/assets/screenshots/ios-presence-before-offline.png new file mode 100644 index 00000000000..756364b6c34 Binary files /dev/null and b/docs/assets/screenshots/ios-presence-before-offline.png differ diff --git a/docs/ios-peer-presence-hydration.md b/docs/ios-peer-presence-hydration.md new file mode 100644 index 00000000000..1895d516d55 --- /dev/null +++ b/docs/ios-peer-presence-hydration.md @@ -0,0 +1,209 @@ +# iOS peer-presence hydration remediation + +## Status + +Implemented and validated from base commit `0f7edef101f2` on the isolated +`fix/ios-peer-presence-hydration` worktree. Source delivery is tracked in pull +request [#25](https://github.com/BrianInAz/buzz/pull/25). On 2026-08-11 the +branch was refreshed through signed-off merge commit `e3b5e4cd9c92` from the +fully verified `origin/main` commit `f73b2bdd5d95`; fresh PR checks and the +final squash merge remain pending. + +- Beads epic: `ios-buzz-59e` +- Pull request: [#25](https://github.com/BrianInAz/buzz/pull/25) +- Verified integration base: `f73b2bdd5d95` +- Merged commit: pending +- Acceptance date: 2026-08-10 +- Promotion revalidation date: 2026-08-11 + +## Problem and root cause + +The iOS client used a live WebSocket subscription for ephemeral kind `20001` +presence events, but it did not hydrate already-online peers when their rows +were first rendered. A live event received before a DM pubkey was tracked was +discarded, and ephemeral events are neither stored nor replayed. Reconnection +cleared the provider cache and recreated the same gap. The result was a gray +peer dot until the next heartbeat happened to arrive even though the iOS relay +session itself was connected. + +The owner's green dot was not evidence of peer presence. It represented the +local relay connection, while each Hermes dot represented a separate +relay-authoritative presence record. + +![Before: peer dots remain gray while the client is connected](assets/screenshots/ios-presence-before-offline.png) + +## Existing service contract + +No new protocol or runtime endpoint was required: + +- The relay already exposes Redis-backed current presence through authenticated + `POST /query` responses for kind `20001`. +- `RelaySessionNotifier.queryRelay()` already signs and submits that query from + mobile. +- The CLI can independently query the same authoritative presence state. +- Desktop already combines an immediate presence snapshot, live WebSocket + updates, and a periodic refresh. + +Relay query results are synthesized snapshots. Their trusted subject is the +`p` tag. Live WebSocket events remain self-authored updates and therefore use +the event author; a live event cannot claim another subject through a forged +`p` tag. + +## Implementation + +`PresenceCacheNotifier` keeps its existing `Map` public +contract and all consuming widgets remain unchanged. + +- `track()` trims, lowercases, and deduplicates pubkeys before asynchronous + work. +- Newly unresolved keys are collected for 50 ms and queried together with + explicit kind `20001` and author filters. Filters are bounded to 100 authors. +- A successful snapshot accepts only `online`, `away`, or `offline`; requested + subjects omitted from that successful snapshot become `offline`. +- A failed query preserves the last-known value and does not manufacture a + state. +- Per-pubkey revisions prevent a late snapshot from overwriting a live update + received while the query was in flight. +- A global kind `20001` WebSocket subscription remains the fast path. +- Connected sessions refresh all tracked keys every 60 seconds so Redis TTL + expiry is eventually reflected even without a live event. +- Disconnect clears stale cache state. Reconnection recreates the subscription + and immediately rehydrates every tracked key. +- Only initial subscription setup is retried, with capped exponential delays. + Query failures are left to the next normal hydration or refresh opportunity. +- Batch, refresh, retry, and subscription resources are cancelled on disposal. + +No Hermes names or pubkeys are hardcoded. No Desktop, CLI, relay, heartbeat, +TTL, authentication, membership, or runtime configuration was changed. + +## Test-driven evidence + +The targeted suite first failed in nine new scenarios while the six existing +live-event tests remained green. The implementation then made all 15 targeted +tests pass. Coverage includes: + +- immediate query-backed hydration without a new heartbeat; +- snapshot `p`-tag trust and author-scoped live events; +- successful omission-to-offline behavior; +- failure preserving the last-known value; +- live-update precedence over an in-flight snapshot; +- normalized, deduplicated widget tracking; +- disconnect, resubscribe, and immediate reconnect hydration; +- periodic TTL-expiry refresh; and +- capped initial subscription retry and disposal. + +Validation completed before delivery: + +| Check | Result | +|---|---| +| Targeted presence provider suite | 15 passed | +| ChannelDetailPage suite | 61 passed | +| Full mobile suite | 1,107 passed, 1 intentional skip, 0 failed | +| Dart formatting | Passed, no changes | +| Flutter analysis | Passed, no issues | +| Mobile file-size policy | Passed | +| `git diff --check` | Passed | +| Repository-wide `just ci` | Passed (Hermit-pinned toolchain) | + +## Promotion revalidation + +The delivery blockers discovered after the first PR publication were fixed as +separate TDD remediations and promoted independently before this branch was +refreshed: + +- [PR #26](https://github.com/BrianInAz/buzz/pull/26), merged as + `ef72743f`, corrected the Desktop path filter so mobile and documentation + changes no longer select Desktop through a standalone negative glob. +- [PR #27](https://github.com/BrianInAz/buzz/pull/27), merged as + `594d8cc5`, delivered the separately authorized mobile scroll-anchor fix. + It is now supplied by `main`, not as an additional presence-specific change. +- [PR #29](https://github.com/BrianInAz/buzz/pull/29), merged as + `ce5acf44`, made the Sprig rolling publication safely create or update + `sprig-latest`. +- [PR #31](https://github.com/BrianInAz/buzz/pull/31), merged as + `ad9e7c31`, brought in the upstream thread-scroll test correction without a + local production workaround. +- [PR #33](https://github.com/BrianInAz/buzz/pull/33), merged as + `f73b2bdd`, fixed the pre-existing stale channel-autocomplete Enter race that + the corrected Desktop smoke test exposed. + +PR #33 passed 14 substantive PR checks, including Desktop Core, all four smoke +shards, both integration shards, and the macOS build. Exact-SHA post-merge +evidence for `f73b2bdd5d95` is also green: + +- [CI run 31535160441](https://github.com/BrianInAz/buzz/actions/runs/31535160441): + passed, including Desktop, mobile, macOS, Windows, relay integration, and + both Linux cross-compiles; +- [Sprig run 31535160539](https://github.com/BrianInAz/buzz/actions/runs/31535160539): + passed; and +- [Helm run 31535160483](https://github.com/BrianInAz/buzz/actions/runs/31535160483): + passed. The Docker workflow was correctly path-skipped. + +The presence branch then merged that verified `main` without force-pushing or +resolving conflicts. Fresh PR #25 validation is required before merge; this +section will record the final PR and post-merge evidence at closeout. + +## Separate baseline scroll defect + +The full mobile gate exposed an unrelated failure already present on untouched +`origin/main`: when follow mode was off and a tall newest message remained +visible, prepending a live row moved that visible message by 98 points. The +user separately authorized a TDD remediation under Beads item +`ios-buzz-59e.7`; it was promoted independently through PR #27 before this +presence branch was refreshed. + +The message list now records a stable visible-message anchor and restores its +measured viewport offset after the reversed list inserts a new row. The +geometry regression proves the tall message stays within one point, the Latest +control remains available, and selecting Latest still reveals the incoming +message. This change is independent of peer presence. + +## Simulator acceptance + +Acceptance used the worktree-specific debug bundle on an iPhone 17 Pro +simulator running iOS 26.5. The production bundle was not replaced. The +physical iPhone running iOS 27 beta had no backup and was explicitly left +untouched. + +The clean simulator initially rejected the private Buzz certificate. After +explicit approval, the existing public Home Lab CA certificate was installed +only into that simulator's trusted root store. No certificate or key material +was added to Git, Beads, or Notion, and no certificate warning was bypassed. + +Authoritative CLI queries before and after acceptance reported both expected +Hermes peers online. The UI was then checked independently: + +| Scenario | Runs | Completed capture time | Result | +|---|---:|---:|---| +| Cold launch | 5 | 4.282-4.914 s | Both peer dots green in every capture | +| Background/foreground reconnect | 5 | 4.735-4.765 s | Both peer dots green in every capture | + +Cold captures around 4.29 seconds still showed the normal loading skeleton; +the completed 4.6-second-request captures establish that hydration finishes +before five seconds rather than waiting for the next heartbeat. + +The owner connection dot remained green, the Hermes Dev DM opened and reported +Online, and the non-mutating multi-recipient New message flow remained +available. Multi-recipient selection and wrapping are also covered by the +green mobile widget suite. No offline runtime was manufactured; successful +snapshot omission-to-offline behavior is covered by the focused regression +test. + +![After: query hydration shows both online peer dots](assets/screenshots/ios-presence-after-hydration.png) + +## Limitations + +- This acceptance proves the iOS 26.5 simulator path. Physical-device + acceptance is deferred until the iOS 27 beta phone has a safe backup. +- Peer presence still follows relay TTL semantics. The 60-second refresh is a + backstop; live events remain the fast path. +- Query failures intentionally preserve the last-known value until a later + successful refresh or reconnect. + +## Rollback + +Revert the delivery pull request and remove the worktree-specific debug app +from the simulator. No relay, Desktop, CLI, or Hermes rollback is required. +Resetting the isolated simulator keychain removes the simulator-only Home Lab +CA trust and its local app credentials if that local prerequisite is no longer +needed. diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart index f735c376c35..9c36771968f 100644 --- a/mobile/lib/features/profile/presence_cache_provider.dart +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -7,46 +7,207 @@ import '../../shared/relay/relay.dart'; /// In-memory cache of other users' presence. /// -/// Subscribes to kind:20001 presence events over the relay WebSocket for -/// real-time updates. There is no longer a REST backstop — agents that -/// publish presence purely over WS are fine, and TTL expiry will be handled -/// by the relay-side `presence:true` filter extension when that lands. +/// The authenticated HTTP query hydrates tracked users from the relay's Redis +/// presence snapshot. Live kind:20001 events remain the fast path, while a +/// periodic refresh catches TTL expiry and reconnection gaps. class PresenceCacheNotifier extends Notifier> { + static const _batchDelay = Duration(milliseconds: 50); + static const _refreshInterval = Duration(seconds: 60); + static const _maxAuthorsPerFilter = 100; + static const _subscriptionRetryDelays = [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 4), + Duration(seconds: 8), + Duration(seconds: 16), + Duration(seconds: 30), + ]; + final Set _tracked = {}; + final Set _pendingHydration = {}; + final Map _revisions = {}; + + Map _cache = const {}; void Function()? _presenceUnsub; + Timer? _batchTimer; + Timer? _refreshTimer; + Timer? _subscriptionRetryTimer; int _subscriptionVersion = 0; + bool _connected = false; + bool _disposed = false; @override Map build() { - final sessionState = ref.watch(relaySessionProvider); - - ref.onDispose(() { - _presenceUnsub?.call(); - _presenceUnsub = null; - }); + _connected = + ref.read(relaySessionProvider).status == SessionStatus.connected; + _cache = const {}; + ref.listen(relaySessionProvider, _handleSessionState); + ref.onDispose(_dispose); - if (sessionState.status == SessionStatus.connected) { - _subscribePresenceUpdates(); + if (_connected) { + Future.microtask(_startConnectedResources); } - return {}; + return _cache; + } + + void _handleSessionState(SessionState? previous, SessionState next) { + final connected = next.status == SessionStatus.connected; + if (_disposed || _connected == connected) return; + + _connected = connected; + _stopConnectedResources(); + _cache = const {}; + state = _cache; + if (_connected) { + Future.microtask(_startConnectedResources); + } } /// Track presence for [pubkeys]. /// - /// Currently a no-op for the actual fetch — we rely on live kind:20001 - /// events. The tracked set is still used to filter incoming events so the - /// cache doesn't grow unbounded. + /// Newly tracked, unresolved keys are normalized and hydrated in one + /// debounced relay query. Repeated widget-driven calls do not issue another + /// query; periodic refresh and reconnection handle later reconciliation. void track(List pubkeys) { - final normalized = pubkeys.map((pk) => pk.toLowerCase()).toList(); - _tracked.addAll(normalized); - // TODO(presence): once the relay supports a `presence:true` filter - // extension, issue a one-shot fetch here for the latest known state per - // pubkey. Until then, presence is "online whenever they publish". + for (final pubkey in pubkeys) { + final normalized = pubkey.trim().toLowerCase(); + if (normalized.isEmpty || !_tracked.add(normalized)) continue; + _pendingHydration.add(normalized); + } + + if (!_connected || _pendingHydration.isEmpty || _batchTimer != null) { + return; + } + _batchTimer = Timer(_batchDelay, _flushPendingHydration); + } + + void _startConnectedResources() { + if (_disposed || !_connected) return; + + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; + unawaited(_subscribePresenceUpdates()); + + _refreshTimer?.cancel(); + _refreshTimer = Timer.periodic(_refreshInterval, (_) { + if (_connected && _tracked.isNotEmpty) { + unawaited(_queryPresence(Set.from(_tracked))); + } + }); + + if (_tracked.isNotEmpty) { + _pendingHydration.clear(); + unawaited(_queryPresence(Set.from(_tracked))); + } } - /// Subscribe to kind:20001 presence events over WebSocket. - Future _subscribePresenceUpdates() async { + void _stopConnectedResources() { + _subscriptionVersion++; + _presenceUnsub?.call(); + _presenceUnsub = null; + _batchTimer?.cancel(); + _batchTimer = null; + _refreshTimer?.cancel(); + _refreshTimer = null; + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; + _pendingHydration.clear(); + } + + void _dispose() { + _disposed = true; + _connected = false; + _stopConnectedResources(); + _tracked.clear(); + _revisions.clear(); + } + + void _flushPendingHydration() { + _batchTimer = null; + if (_disposed || !_connected) return; + + final unresolved = _pendingHydration + .where((pubkey) => !_cache.containsKey(pubkey)) + .toSet(); + _pendingHydration.clear(); + if (unresolved.isNotEmpty) { + unawaited(_queryPresence(unresolved)); + } + } + + Future _queryPresence(Set requested) async { + if (_disposed || !_connected || requested.isEmpty) return; + + final pubkeys = requested.toList()..sort(); + final requestRevisions = {}; + for (final pubkey in pubkeys) { + final revision = (_revisions[pubkey] ?? 0) + 1; + _revisions[pubkey] = revision; + requestRevisions[pubkey] = revision; + } + + final filters = []; + for ( + var offset = 0; + offset < pubkeys.length; + offset += _maxAuthorsPerFilter + ) { + final end = offset + _maxAuthorsPerFilter < pubkeys.length + ? offset + _maxAuthorsPerFilter + : pubkeys.length; + final authors = pubkeys.sublist(offset, end); + filters.add( + NostrFilter( + kinds: const [EventKind.presenceUpdate], + authors: authors, + limit: authors.length, + ), + ); + } + + final List events; + try { + events = await ref + .read(relaySessionProvider.notifier) + .queryRelay(filters); + } catch (error) { + debugPrint('[PresenceCacheNotifier] presence query failed: $error'); + return; + } + + if (_disposed || !_connected) return; + + final latest = {}; + for (final event in events) { + if (event.kind != EventKind.presenceUpdate) continue; + final subject = event.getTagValue('p')?.trim().toLowerCase(); + final status = _validStatus(event.content); + if (subject == null || !requested.contains(subject) || status == null) { + continue; + } + final previous = latest[subject]; + if (previous == null || event.createdAt > previous.createdAt) { + latest[subject] = (createdAt: event.createdAt, status: status); + } + } + + final updated = Map.from(_cache); + var changed = false; + for (final pubkey in pubkeys) { + if (_revisions[pubkey] != requestRevisions[pubkey]) continue; + final status = latest[pubkey]?.status ?? 'offline'; + if (updated[pubkey] == status) continue; + updated[pubkey] = status; + changed = true; + } + if (changed) _publish(updated); + } + + /// Subscribe to live kind:20001 updates, retrying only subscription setup. + Future _subscribePresenceUpdates([int attempt = 0]) async { + if (_disposed || !_connected) return; + _presenceUnsub?.call(); _presenceUnsub = null; _subscriptionVersion++; @@ -58,29 +219,54 @@ class PresenceCacheNotifier extends Notifier> { const NostrFilter(kinds: [EventKind.presenceUpdate], limit: 0), _handlePresenceEvent, ); - // Guard: if build() re-fired while we were awaiting, discard this - // subscription to avoid leaking it. - if (version != _subscriptionVersion) { + if (_disposed || !_connected || version != _subscriptionVersion) { unsub(); return; } + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; _presenceUnsub = unsub; } catch (error) { + if (_disposed || !_connected || version != _subscriptionVersion) return; debugPrint( '[PresenceCacheNotifier] presence subscription failed: $error', ); + final index = attempt < _subscriptionRetryDelays.length + ? attempt + : _subscriptionRetryDelays.length - 1; + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = Timer( + _subscriptionRetryDelays[index], + () => unawaited(_subscribePresenceUpdates(attempt + 1)), + ); } } void _handlePresenceEvent(NostrEvent event) { - final pubkey = event.pubkey.toLowerCase(); + if (_disposed || event.kind != EventKind.presenceUpdate) return; + final pubkey = event.pubkey.trim().toLowerCase(); if (!_tracked.contains(pubkey)) return; - final status = event.content; - if (status != 'online' && status != 'away' && status != 'offline') return; - if (state[pubkey] == status) return; - final updated = Map.from(state); - updated[pubkey] = status; - state = updated; + final status = _validStatus(event.content); + if (status == null) return; + + _revisions[pubkey] = (_revisions[pubkey] ?? 0) + 1; + if (_cache[pubkey] == status) return; + final updated = Map.from(_cache)..[pubkey] = status; + _publish(updated); + } + + String? _validStatus(String content) { + final status = content.trim(); + return switch (status) { + 'online' || 'away' || 'offline' => status, + _ => null, + }; + } + + void _publish(Map updated) { + if (_disposed) return; + _cache = Map.unmodifiable(updated); + state = _cache; } } diff --git a/mobile/test/features/profile/presence_cache_provider_test.dart b/mobile/test/features/profile/presence_cache_provider_test.dart index 9fc02d9575b..44743804a22 100644 --- a/mobile/test/features/profile/presence_cache_provider_test.dart +++ b/mobile/test/features/profile/presence_cache_provider_test.dart @@ -1,148 +1,368 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:buzz/features/profile/presence_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/features/profile/presence_cache_provider.dart'; -import 'package:buzz/shared/relay/relay.dart'; -/// Tests for [PresenceCacheNotifier] in the pure-Nostr world. -/// -/// The cache is now purely WS-driven: the notifier subscribes to kind:20001 -/// (presence updates) over the relay session and only mutates state for -/// pubkeys that have been registered via [PresenceCacheNotifier.track]. -/// There is no longer a REST backstop — the previous test seeded state via -/// a `GET /api/presence` call which has been removed. void main() { + testWidgets( + 'track batches an authenticated query and hydrates online presence', + (tester) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([_snapshot('alice', 'online')]); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + + container.read(presenceCacheProvider.notifier).track(['ALICE']); + await tester.pump(const Duration(milliseconds: 49)); + expect(relaySession.queries, isEmpty); + + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + + expect(relaySession.queries, hasLength(1)); + expect(relaySession.queries.single, hasLength(1)); + expect(relaySession.queries.single.single.kinds, [ + EventKind.presenceUpdate, + ]); + expect(relaySession.queries.single.single.authors, ['alice']); + expect(relaySession.queries.single.single.limit, 1); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + container.dispose(); + }, + ); + + testWidgets( + 'snapshots trust p-tag subjects while live events remain author-scoped', + (tester) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([ + _snapshot('alice', 'online', author: 'relay-author'), + ]); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice', 'bob']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect(container.read(presenceCacheProvider)['alice'], 'online'); + expect(container.read(presenceCacheProvider)['relay-author'], isNull); + + relaySession.emit( + _presence( + 'alice', + 'away', + tags: const [ + ['p', 'bob'], + ], + ), + ); + + expect(container.read(presenceCacheProvider)['alice'], 'away'); + expect(container.read(presenceCacheProvider)['bob'], 'offline'); + container.dispose(); + }, + ); + + testWidgets('successful snapshot omission records offline', (tester) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([]); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect(container.read(presenceCacheProvider)['alice'], 'offline'); + container.dispose(); + }); + + testWidgets('query failure preserves the last known value', (tester) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([_snapshot('alice', 'online')]) + ..enqueueQueryError(StateError('query failed')); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + await tester.pump(const Duration(seconds: 60)); + await tester.pump(); + + expect(relaySession.queries, hasLength(2)); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + container.dispose(); + }); + + testWidgets('live update wins over an older in-flight snapshot', ( + tester, + ) async { + final query = Completer>(); + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryFuture(query.future); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice']); + await tester.pump(const Duration(milliseconds: 50)); + expect(relaySession.queries, hasLength(1)); + + relaySession.emit(_presence('alice', 'away')); + expect(container.read(presenceCacheProvider)['alice'], 'away'); + + query.complete([_snapshot('alice', 'online')]); + await tester.pump(); + + expect(container.read(presenceCacheProvider)['alice'], 'away'); + container.dispose(); + }); + + testWidgets('repeated track calls normalize without query storms', ( + tester, + ) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([_snapshot('alice', 'online')]); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + final notifier = container.read(presenceCacheProvider.notifier); + notifier.track([' ALICE ', 'alice']); + notifier.track(['Alice']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect(relaySession.queries, hasLength(1)); + expect(relaySession.queries.single.single.authors, ['alice']); + + notifier.track(['ALICE']); + await tester.pump(const Duration(milliseconds: 100)); + expect(relaySession.queries, hasLength(1)); + container.dispose(); + }); + + testWidgets('reconnection clears stale state and immediately rehydrates', ( + tester, + ) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([_snapshot('alice', 'online')]) + ..enqueueQueryResult([_snapshot('alice', 'online')]); + final container = _buildContainer(relaySession: relaySession); + final presenceListener = container.listen(presenceCacheProvider, (_, _) {}); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + relaySession.setStatus(SessionStatus.disconnected); + await tester.pump(); + expect(container.read(presenceCacheProvider), isEmpty); + expect(relaySession.activeListenerCount, 0); + + relaySession.setStatus(SessionStatus.connected); + await tester.pump(); + await tester.pump(); + + expect(relaySession.queries, hasLength(2)); + expect(relaySession.subscribeCallCount, 2); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + presenceListener.close(); + container.dispose(); + }); + + testWidgets( + 'periodic refresh detects TTL expiry while live events stay the fast path', + (tester) async { + final relaySession = _RecordingRelaySessionNotifier() + ..enqueueQueryResult([_snapshot('alice', 'online')]) + ..enqueueQueryResult([]); + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + container.read(presenceCacheProvider.notifier).track(['alice']); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + relaySession.emit(_presence('alice', 'away')); + expect(container.read(presenceCacheProvider)['alice'], 'away'); + + await tester.pump(const Duration(seconds: 60)); + await tester.pump(); + expect(relaySession.queries, hasLength(2)); + expect(container.read(presenceCacheProvider)['alice'], 'offline'); + + container.dispose(); + await tester.pump(const Duration(seconds: 60)); + expect(relaySession.queries, hasLength(2)); + }, + ); + + testWidgets('failed initial subscription retries with backoff', ( + tester, + ) async { + final relaySession = _RecordingRelaySessionNotifier() + ..subscribeFailuresRemaining = 1; + final container = _buildContainer(relaySession: relaySession); + + container.read(presenceCacheProvider); + await tester.pump(); + expect(relaySession.subscribeCallCount, 1); + + await tester.pump(const Duration(milliseconds: 999)); + expect(relaySession.subscribeCallCount, 1); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + + expect(relaySession.subscribeCallCount, 2); + expect(relaySession.activeListenerCount, 1); + container.dispose(); + }); + test('WS presence event updates cache for tracked pubkey', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); - // Initialize the notifier (triggers build → subscribes to WS). container.read(presenceCacheProvider); await _pumpEventQueue(); - - // Track alice, then emit her initial 'online' status. container.read(presenceCacheProvider.notifier).track(['alice']); + relaySession.emit(_presence('alice', 'online')); expect(container.read(presenceCacheProvider)['alice'], 'online'); - // Simulate a WS presence event: alice goes away. relaySession.emit(_presence('alice', 'away')); expect(container.read(presenceCacheProvider)['alice'], 'away'); + container.dispose(); }); test('WS presence event ignores untracked pubkeys', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); container.read(presenceCacheProvider); await _pumpEventQueue(); - - // Track only alice. container.read(presenceCacheProvider.notifier).track(['alice']); - // Emit event for bob (untracked). relaySession.emit(_presence('bob', 'online')); - - // Bob should NOT appear in the cache. expect(container.read(presenceCacheProvider).containsKey('bob'), isFalse); + container.dispose(); }); test('WS presence event ignores invalid status values', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); container.read(presenceCacheProvider); await _pumpEventQueue(); - container.read(presenceCacheProvider.notifier).track(['alice']); relaySession.emit(_presence('alice', 'online')); - expect(container.read(presenceCacheProvider)['alice'], 'online'); - // Emit event with garbage status — should be rejected. relaySession.emit(_presence('alice', 'garbage-status')); - - // Status should remain 'online'. expect(container.read(presenceCacheProvider)['alice'], 'online'); + container.dispose(); }); test('WS presence event skips no-op updates', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); container.read(presenceCacheProvider); await _pumpEventQueue(); - container.read(presenceCacheProvider.notifier).track(['alice']); relaySession.emit(_presence('alice', 'online')); - // Listen for state changes after initial setup. var stateChangeCount = 0; container.listen(presenceCacheProvider, (prev, next) => stateChangeCount++); - - // Emit event with same status as current. relaySession.emit(_presence('alice', 'online')); - // No state change should occur — it's a no-op. expect(stateChangeCount, 0); + container.dispose(); }); test('subscribes to kind:20001 with limit 0', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); container.read(presenceCacheProvider); await _pumpEventQueue(); - // Should have subscribed with the correct filter. expect(relaySession.filters, hasLength(1)); expect(relaySession.filters.single.kinds, [EventKind.presenceUpdate]); expect(relaySession.filters.single.limit, 0); + container.dispose(); }); - test('WS event uses pubkey variable, not literal string', () async { - // Regression test for the map key bug where `{...state, pubkey: status}` - // used the literal string "pubkey" instead of the variable's value. + test('WS event uses the actual pubkey as the map key', () async { final relaySession = _RecordingRelaySessionNotifier(); final container = _buildContainer(relaySession: relaySession); - addTearDown(container.dispose); container.read(presenceCacheProvider); await _pumpEventQueue(); - container.read(presenceCacheProvider.notifier).track([ 'deadbeef', 'cafebabe', ]); - // Seed cafebabe -> offline, then set deadbeef online. relaySession.emit(_presence('cafebabe', 'offline')); relaySession.emit(_presence('deadbeef', 'online')); final cache = container.read(presenceCacheProvider); - // deadbeef should be online (the actual pubkey, not a literal "pubkey" key). expect(cache['deadbeef'], 'online'); - // cafebabe should still be offline (not clobbered). expect(cache['cafebabe'], 'offline'); - // There should be no literal "pubkey" key in the map. expect(cache.containsKey('pubkey'), isFalse); + container.dispose(); }); } -NostrEvent _presence(String pubkey, String status) => NostrEvent( +NostrEvent _presence( + String pubkey, + String status, { + List> tags = const [], +}) => NostrEvent( id: 'evt-$pubkey-$status', pubkey: pubkey, createdAt: 1000, kind: EventKind.presenceUpdate, - tags: const [], + tags: tags, content: status, sig: 'sig', ); +NostrEvent _snapshot( + String subject, + String status, { + String author = 'relay', + int createdAt = 1000, +}) => NostrEvent( + id: 'snapshot-$subject-$status-$createdAt', + pubkey: author, + createdAt: createdAt, + kind: EventKind.presenceUpdate, + tags: [ + ['p', subject], + ], + content: status, + sig: 'relay-sig', +); + Future _pumpEventQueue() async { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); @@ -161,17 +381,54 @@ ProviderContainer _buildContainer({ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { final List filters = []; + final List> queries = []; final List _listeners = []; + final Queue> Function()> _queryPlans = Queue(); + int subscribeCallCount = 0; + int subscribeFailuresRemaining = 0; + + int get activeListenerCount => _listeners.length; @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionStatus status) { + state = SessionState(status: status); + } + + void enqueueQueryResult(List events) { + _queryPlans.add(() async => events); + } + + void enqueueQueryFuture(Future> events) { + _queryPlans.add(() => events); + } + + void enqueueQueryError(Object error) { + _queryPlans.add(() async => throw error); + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) { + queries.add(List.unmodifiable(filters)); + if (_queryPlans.isEmpty) return Future.value(const []); + return _queryPlans.removeFirst()(); + } + @override Future subscribe( NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, }) async { + subscribeCallCount++; + if (subscribeFailuresRemaining > 0) { + subscribeFailuresRemaining--; + throw StateError('subscription failed'); + } filters.add(filter); _listeners.add(onEvent); return () { @@ -180,7 +437,6 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { }; } - /// Emit an event synchronously to all live subscribers. void emit(NostrEvent event) { for (final listener in List.of(_listeners)) { listener(event);