Skip to content

feat(relay): add incremental artifact reads - #8523

Merged
dr0uleau merged 21 commits into
mainfrom
drouleau/artifact-snapshots
Oct 30, 2026
Merged

dr0uleau merged 21 commits into
mainfrom
drouleau/artifact-snapshots

Conversation

@dr0uleau

@dr0uleau dr0uleau commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Buzz clients need to recover task changes after a disconnect. This adds incremental reads of changed artifacts with database modification timestamps and a 120-second overlap. Each result contains the current revision or an explicit removal. A separate read of known IDs confirms accepted edits and restores access state.

Pages use a timestamp and a stable ID to handle equal timestamps. Access grants include older records. Access policy changes require discovery and revalidation of saved IDs. The reader does not load a list of all readable channels. Ordinary artifact writes update their own rows.

Incremental reads and WebSocket Sync

This HTTP protocol maintains an eventually consistent local store. It uses artifacts.incremental and "artifact":"incremental". It does not prove a complete set at one database snapshot. Absence never proves deletion or loss of access.

WebSocket Sync serves a separate use case: a complete set at EOSE, followed by live updates. It owns artifacts.sync. Both protocols can coexist. The second PR to merge must reuse the shared access helper in artifact_query.rs when it resolves the overlapping edits.

The gate is BUZZ_ARTIFACT_INCREMENTAL_ENABLED. Errors use the incremental_ prefix. Unlisted open channels require membership or an explicit #h scope. Listing changes reset incremental progress. Incremental reads exclude TTL artifacts. Known-ID reads return remove for them. The #h scope limits candidates before pagination. Initial reads omit deleted heads. Later reads retain deletion removals.

Why the new timestamps are needed

created_at comes from the event signer. received_at records event arrival. Neither advances when access changes or an event is redacted. Sync needs database timestamps for those changes:

Column Why sync needs it
artifact_heads.modified_at Finds current records that change through an edit, deletion, or redaction.
artifact_revisions.modified_at Dates retained move records. This lets sync remove a task from its former channel when the reader cannot access its new channel.
channel_members.sync_modified_at Finds grants, rejoins, and removals. A grant must include older tasks whose own timestamps do not change.
communities.artifact_policy_modified_at Requests recovery after a relevant channel visibility, TTL, or deletion change. Clients revalidate saved IDs. Ordinary task writes do not update it.

The database sets these timestamps with clock_timestamp(). The 120-second overlap covers the accepted delay between timestamp assignment and commit visibility.

BUZZ_ARTIFACT_INCREMENTAL_ENABLED defaults to off. Apply migrations 0076–0078 and upgrade all writers before enablement. The 120-second delay bound is an operating assumption. Deployment steps cover index prebuilds and rollback.

Related issue

No duplicate found. Related work: read permissions, statement limits, and atomic channel settings.

Testing

Signed HTTP E2E passes on f3d6ac852e7ce4e2cc53bd891cef87832a868d72 after CI passes. It includes main through ef4130da4. A standalone local relay uses a fresh PostgreSQL 17 database, Redis, and signed NIP-98 HTTP requests. All 78 migrations apply through relay startup. All behavioral writes and reads use HTTP.

The tests cover create, edit, delta reads, known-ID recovery, private moves, access grants, removals, rejoins, visibility resets, redaction, and deletion. Unlisted tests cover hidden broad discovery, named scope, known-ID reads, member access, and policy resets. They also cover bootstrap across two pages, overlap replay, and 101 move removals across pages. Decoded cursors contain no private revision IDs. With the incremental gate off, artifacts.incremental is absent and a signed incremental request returns 503 incremental_unavailable. Unsigned requests are rejected. Signed tests also cover TTL exclusion, current-ID removals for TTL artifacts, moves into TTL channels, deleted initial heads, and scoped pages with exactly 100 and 3 upserts.

A separate local scale test on babcdb766 reads 10,050 tasks across 60,002 memberships. It uses seeded fixtures and a second relay state with the same signing key. The initial read takes 8.31 seconds over 101 pages with 4,923-byte events. A read with no changes takes 5.70 milliseconds. The candidate keeps the same database query. These are local measurements, not production limits. Cold pages repeat candidate scans and access checks. Writer load needs validation before fleet enablement.

cargo test -p buzz-relay sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships -- --ignored --nocapture

The behavioral run uses cargo build -p buzz-relay --bin buzz-relay, then cargo run -p buzz-relay --example artifact_incremental_final_oct30_f3. The temporary harness drives the built relay and is removed after the run. The worktree is clean at the tested SHA. It does not exercise a native client. Git and S3 behavior are outside this validation.

The protocol defines progress, removals, limits, and recovery. Relay verification covers the signed HTTP path. Native client verification belongs to the client PR.

Generated with Codex

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
…pshots

* origin/main:
  feat(relay): add announce channels with a join_role setting (#8143)

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
…pshots

* origin/main:
  fix(db): bound every statement and the #p mention scan (#8461)

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@dr0uleau
dr0uleau marked this pull request as ready for review October 22, 2026 00:11
@dr0uleau
dr0uleau requested a review from a team as a code owner October 22, 2026 00:11
@dr0uleau dr0uleau changed the title feat(relay): add consistent artifact snapshots feat(relay): sync artifacts with a time overlap Oct 22, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is d263e2c7f7126b9a5caf1507140bb32e2c4e0ea3...f3d6ac852e7ce4e2cc53bd891cef87832a868d72.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review f3d6ac852e7ce4e2cc53bd891cef87832a868d72 to authorize a new review.
Any previous review applies only to its recorded range.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account.

One P2 correctness finding is inline: a concurrent first artifact can escape the conditional policy reset when its channel becomes private or is deleted. No code changes were applied.

Reviewed head 6a52195bd9a387e4871e3689b5c942ae42a5d597 against base/merge-base 2f2f6e2774fb0899d2a927f5e5b1987603765f31, with independent Rocket (systems/database), Mantis (correctness/security/concurrency), and Groot (protocol/user flows) lanes plus my integration.

Source-only review; no tests, builds, SQL or app execution. A single CI snapshot showed 50 successful, 28 skipped and 1 in-progress checks. Production query costs, migrations/rollback, client recovery and the documented 120-second stamp-to-visibility assumption remain unverified. Matching TTL artifacts intentionally return 422; clients must account for that permanent-channel-only scope.

Optional documentation clarification: spell out applying 0068 after its index prebuild, then 0069, before building the 0070 indexes; the current runbook jumps from the 0068 prebuild to 0069. Public PR description inspected; no attached images or actionable disclosure found.

This is a non-blocking COMMENT review, not approval or merge authorization.

Comment thread crates/buzz-db/src/store/channel.rs Resolved
@dr0uleau
dr0uleau requested a review from a team October 22, 2026 00:41
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
…pshots

* origin/main:
  fix(db): keep pgschema re-apply from dropping runtime partitions (#8487)

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 22, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 22, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source follow-up via Wes’s account.

The prior P2 is fixed: actual visibility/TTL changes and successful channel deletion now advance the policy stamp even when the channel has no artifacts. The first-artifact interleaving test covers both transitions, and the deployment instructions now include 0068 application and migration-ledger recording.

One P2 remains inline, independently confirmed from the new security-review evidence: a continuation can expose an inaccessible destination revision ID. This is separate from the repaired policy-stamp race.

Reviewed head 357da40b3941efd083ea053fab56dbef3cce619c against base/merge-base 347ce88d5dd8bc631c073f1947fea6b75d5a43c0, with independent Rocket (systems/database), Mantis (correctness/security), and Groot (protocol/user flows) lanes plus my integration. Range-diff preserves all six previously reviewed feature patches; this pass covers the follow-up changes and the newly evidenced cursor issue.

Source only: no tests, builds, SQL or apps executed. One CI snapshot showed 29 successful, 21 skipped and 5 in-progress checks. Runtime interleavings, rollout/rollback, reset costs, client recovery and the 120-second visibility assumption remain unverified. Public description inspected; no attached images or actionable disclosure there. Non-blocking COMMENT review; no approval or merge authorization.

@dr0uleau
dr0uleau requested a review from a team October 22, 2026 01:27
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 22, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source follow-up via Wes’s account.

The prior P2 cursor disclosure is fixed. Move-source continuation positions now contain artifact IDs instead of inaccessible destination revision IDs; DISTINCT preserves unique equal-timestamp positions. The current-ID read retains the caller’s query predicates and intersects them with the requested artifact IDs.

No new actionable findings in the follow-up from 357da40b3941efd083ea053fab56dbef3cce619c to reviewed head 4019c1df8d266701de685f9c714f747c1781967a, against base/merge-base 347ce88d5dd8bc631c073f1947fea6b75d5a43c0. Rocket, Mantis, and Groot independently reviewed systems/pagination, correctness/security, and product/protocol behavior; Star Lord integrated the results. The public description and changed commit/source material had no actionable disclosure finding; the description contains no attached images.

Source only: no tests, SQL, builds, or app workflows were executed. The new tests exercise the production HTTP router with fixture identity headers and direct database ingestion; this review does not establish signed-HTTP authentication coverage. CI snapshot: 43 successful checks, 28 skipped, 3 still running. Query-plan cost of DISTINCT, production latency/concurrency, client recovery and native accessibility/focus behavior remain unverified. The documented disabled-by-default rollout gate and 120-second overlap assumption remain applicable. This COMMENT is not approval or merge authorization.

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 28, 2026
@dr0uleau
dr0uleau requested a review from gilgtm October 28, 2026 23:09
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Oct 28, 2026
Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 29, 2026

@ngoodger-block ngoodger-block left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed the sync core (timestamps, paging, move/grant/removal paths, lock ordering) and it looks sound; two issues inline. One operational risk: every channel delete (unconditionally) or visibility/unlisted/TTL change resets every client in the community to a full re-bootstrap, and with the per-process Semaphore(4) + try_acquire the resulting burst mostly gets a 503 with no Retry-After. Worth adding client backoff or a Retry-After before fleet enablement.

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>

@ngoodger-block ngoodger-block left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Both inline comments are fixed in f3d6ac8 (TTL channels are excluded from candidates; #h now limits candidates before pagination, and the initial read skips deleted heads). Two non-blocking notes: on populated databases, 0076/0078 will fail unless the indexes are built concurrently before deploy (relevant if BUZZ_AUTO_MIGRATE is on), and before turning on BUZZ_ARTIFACT_INCREMENTAL_ENABLED across the fleet, please add jitter to the client's fixed 60s capacity backoff (or have the relay send Retry-After) so community-wide resets don't retry in lockstep against Semaphore(4).

@dr0uleau
dr0uleau merged commit a57349b into main Oct 30, 2026
90 checks passed
@dr0uleau
dr0uleau deleted the drouleau/artifact-snapshots branch October 30, 2026 15:30
loganj added a commit that referenced this pull request Oct 30, 2026
#8523 added Incremental reads with a link to Sync. Add the reverse link,
and state the difference: incremental reads do not prove a complete set.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
ravarora2 added a commit that referenced this pull request Oct 30, 2026
…vent-guards

* origin/main: (29 commits)
  Restyle community invitations (#8728)
  feat(relay): add incremental artifact reads (#8523)
  fix(mobile): show agent DM typing and balance reply bubble padding (#8731)
  perf(relay): route author- and #p-scoped global subscriptions by selector (#8666)
  fix(desktop): fix the product races behind four E2E flakes (#8680)
  test(scripts): stop file-size fixtures leaking git repos and fsmonitor daemons (#8727)
  test(mobile): keep late callbacks and fixed sleeps from failing tests (#8678)
  fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722)
  ci(loadgen): publish buzz-loadgen as ghcr.io/block/buzz-loadgen (#8717)
  test(relay): fix main's build after the test-state helper rename (#8718)
  fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676)
  test: dedupe collect_until_eose by event id and uncap two shell tests (#8677)
  ci: cache the Tauri apt .debs and fit a cold Windows Rust run in its cap (#8679)
  test(desktop): gate flaky E2E specs on the state they assume (#8681)
  test(desktop): settle composer edits and press hover-only row actions by keyboard (#8682)
  test(desktop): measure inline video controls against their player (#8683)
  relay: open the search pool lazily so a dead reader can't block boot (#8662)
  fix(mobile): address #8650 second-pass review (#8691)
  Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690)
  fix: don't notify workflow owners through their attribution tag (#8694)
  ...

Signed-off-by: Ravneet Arora <rarora@squareup.com>
loganj added a commit that referenced this pull request Oct 30, 2026
🤖
## Summary
Clients that show a list of artifacts (tasks, config entries and similar
records) had no way to get the complete list and then stay current. They
could only page through query results, and a page is not proof that the
list is complete. This PR adds **sync**. A client sends one subscription
and gets every matching artifact as one consistent snapshot, then a
signal that the set is complete. After that it gets the current version
of each artifact that changes. When the relay cannot guarantee an exact
copy, it closes the subscription with a `resync` reason, so the client
never misses a change without knowing it.

It also adds a **head lookup**. It returns the current version of named
artifacts, deleted ones included, so that "create or restore" flows can
retry safely.

This implements the Sync section of NIP-AR (the artifact protocol spec),
which is the first commit of this PR.

## Details
- **Request.** A normal `REQ` whose only filter has `"artifact":
"current"` and at least one channel in `#h`. This is the same
current-state filter as HTTP `/query`; on a `REQ` it becomes a sync,
because a `REQ` already means "stored set, `EOSE`, then live". The
client does not page: the relay streams the whole set until `EOSE`, so
`limit` and `offset` are refused. Other artifact views (`history`,
`head`) stay HTTP-only, as on main. Other clients and other `REQ` shapes
do not change.
- **Initial set.** The relay first registers for live changes and waits
until Redis confirms that registration. Then it reads every matching
current version in pages ordered by artifact ID, all inside one
read-only `REPEATABLE READ` transaction. So all pages come from the same
database snapshot. `EOSE` comes only after a complete read. A failed
read, an unreadable channel, or a read that takes too long ends with
`CLOSED` and no `EOSE`.
- **Live changes.** Fan-out never sends the published event to a sync.
It only records which artifact changed. The sync then reads that
artifact's current version and sends it, one artifact at a time. So a
client never gets an older version after a newer one. Changes that
arrive during the initial read are held and sent after `EOSE`. Changes
are signalled per channel, so a sync skips an artifact whose `type` or
`d` does not match its filter: those never change, so it was never in
the set. A board that syncs three record types in the same channels no
longer gets each change three times.
- **When the relay sends `resync`.** The Redis link drops, reconnects,
or skips a message it cannot decode. Local fan-out lags. The
connection's send queue is full. A moderator redacts an event in the
channel. The reader's channel access changes (remove, leave, metadata
edit, channel delete). An access change ends the whole sync, also when
the sync names other channels. A sync cannot drop one channel and still
claim an exact set.
- **Publish retry.** When a Redis publish fails for an event kind that
syncs use, the relay retries it in the background until it succeeds.
- **Relay info (NIP-11).** The relay now lists `sync` (`max_events:
4096`), adds the `head` mode, and sets `websocket_query_extensions:
true`.
- **No migration.** Sync reads the existing `artifact_heads` table.
- **Next to incremental reads (#8523).** #8523 added a separate HTTP
protocol, `artifacts.incremental` / `"artifact":"incremental"`. It keeps
a saved store up to date after a disconnect, but it does not prove a
complete set. The two protocols use different NIP-11 keys and filter
values, so they do not collide. They share one copy of the current-state
access rule: `artifact_query::predicates`, which #8523 reads through,
now applies the view join and calls `push_scope`, and the sync snapshot
and head lookup call `push_scope` too.
- **A failed access lookup in fan-out resyncs.** The access gate fails
closed when a visibility or membership lookup fails. For a sync, that
would drop a change with no signal, so the gate also reports those
recipients and each one gets `resync: access check failed`.

### Known cost
To find out which redactions remove an artifact that a sync covers, the
relay would need an extra database read. So every redaction in a synced
channel resyncs that channel's syncs, and so does every channel metadata
edit. Each resync reads the set again, up to 4,096 artifacts. This is
correct, but it adds load to channels where people often delete chat
messages. We can narrow it later if it becomes a problem.

### Related issue
None found. The design was reviewed as NIP-AR Sync r28.

### Testing
- New Postgres tests: keyset pages see one snapshot, and an artifact
created after the snapshot opens is in no page; the head view includes
deletions; a private or unknown channel is refused.
- New relay end-to-end tests on real Postgres and Redis, using the real
`REQ` handler, ingest and fan-out: initial set in order, then `EOSE`,
then current versions for an update and a delete; a chat message causes
no frame; `CLOSE` stops the sync. Other tests: removing the reader from
one channel resyncs the whole sync; an unreadable or unknown channel
gets `CLOSED restricted` with no `EOSE`; a node-wide loss resyncs.
- Unit tests for the parser, request routing, fan-out diversion, the
held-change limit, and access-change signals that must arrive before the
access filter.
- Lifecycle tests from an adversarial review: every frame is checked
against the subscription owner, also when a `CLOSE` or a replacing `REQ`
arrives while the frame waits for queue room or while a live head read
is in progress. A resync recorded while `EOSE` waits ends the sync with
no `EOSE`. The held list refuses a change before it stores it (4096
entries, 4 MiB of held removals).
- End to end: a relay container built from this branch, with the
buzz-app #1523 browser build and the Work plugin. An edit from another
client showed in the open board 4 to 25 ms after the write returned,
with no refresh.
- `cargo clippy --all-targets -D warnings` is clean for the four changed
crates. All `buzz-relay` lib tests pass except
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`. That
test returns 504 on this machine. It is a mesh networking test, and this
PR does not touch that code.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
johnmatthewtennant added a commit that referenced this pull request Oct 31, 2026
…e-agent-audience

* origin/main:
  test(mobile): check the title fade's animation, wait 10 s for the alert (#8741)
  test(db): drop scratch databases and roles even when a test fails (#8747)
  test: stop desktop, buzz-agent and mobile tests leaking temp files (#8748)
  test(desktop): keep unit tests away from the real ~/.buzz nest (#8749)
  feat(mobile): show conversation activity by the composer, as buzz-app does (#8765)
  feat(mobile): show your agents working in a thread on its root message (#8754)
  Clarify mobile community onboarding and saved identity selection (#8729)
  Add Buzz background and flying bees to invitations (#8763)
  Polish mobile navigation and restore community switch animation (#8732)
  feat(loadgen): add a hot-thread stressor and teardown (#8746)
  feat(relay): NIP-AR sync subscriptions (#8522)
  fix(mobile): retain invite keys and reject incomplete onboarding (#8753)
  Restyle community invitations (#8728)
  feat(relay): add incremental artifact reads (#8523)
  fix(mobile): show agent DM typing and balance reply bubble padding (#8731)

Signed-off-by: John Tennant <jtennant@squareup.com>
matt2e added a commit that referenced this pull request Oct 31, 2026
Units 6-9 of the 23-30 Oct desktop Playwright flake audit. All are
test-only. origin/main (1eb67bb, now including #8680) hasn't touched
these specs since fae3f03. No open PR touches the tests changed here:
#6134 (conflicting since 10-19) only swaps page.goto calls in three of
these files, #8187's hunk starts just after workflow-local-controls:151,
#5778 (draft, conflicting) creates projects-v3-screenshots from an
older base, and #8622, #7452, #7812, #8244 and #3342 edit other tests
in the same files.

Unit 6: measuring before an animation finishes

onboarding.spec.ts:1487, "hosted community address line stays within
the card for a long name"

Run: #8715 (Integration shard 1), which doesn't touch onboarding.
Signature: the centring check at :1545 expects <= 2 and receives 5.13.

Cause: "Connect and continue" ends in showPage("owned")
(WelcomeSetup.tsx:323), which mounts an OnboardingSlideTransition. Its
content slides in from translateX(48px) over 650 ms (components.css:521,
:676). fill() doesn't wait for stability, and the surface, input and
suffix boxes come from three boundingBox round trips, so they can be
read at different points of the slide.

Change: waitForAnimations(page) after the fill, before measuring.

onboarding.spec.ts:2289, "connected first-community profile keeps
navigation inside the card and balances the avatar editor"

Runs: main 40746677244 and 40925172215, and #8666 (Integration
shard 1). Signature: the spacing check at :2485 expects <= 4 and
receives 4.255.

Cause: the avatar dialog opens with MODAL_CONTENT_MOTION_CLASS, a 200 ms
zoom-in-95 (modalMotion.ts:5), and has a 250 ms height transition.
dialogBox is read in one round trip and the preview and editor boxes in
a later one, so a scale change between them skews the gap comparison.

Change: waitForAnimations(page) once the dialog is visible.

projects-v3-screenshots.spec.ts:280, "projects v3 workspace screenshot
states", failing in the expectInsetSection call at :893

Runs: main 39643531998, 40759209995 and 40969909906 (Smoke shard 8),
each passing on retry. Signature: the header's right inset at :326
expects 16 and receives 8. Four more runs fail the menu edge check at
:328 with fractional values (expected 984, received 975.6875):
39622053330, 39641311877, 40281448810 and 40856574572.

Cause: switching the context rail back from chat to repository narrows
it from the thread panel's 380 px to the repository's 280 px
(useProjectPanelWidths.ts:5, :17), animated by transition-[width]
duration-200 (ProjectContextRail.tsx:31). The workspace widens with it,
and expectInsetSection reads the workspace, header and menu boxes in
three separate round trips. The fractional widths are mid-transition
values.

Change: waitForAnimations(page) before that call, the only one that
follows a rail width change.

Unit 7: fill() racing a caret move the app makes a frame later

mention-recipients.spec.ts:376, "editing to a longer typed member
drops the original shorter reference", and :97, "ambiguous added
mention blocks editing before clearing the draft"

Runs: main 40969965981 (Smoke shard 5, a run with every earlier fix)
for :376. 40969858676 for :97, before the fixes. Signature: the
edit_message poll at :428 receives null. The error context shows the
edited message as "@scout hello @scout Jones hello": fill() appended.

Cause: entering edit mode sets the content, then focuses its end in a
requestAnimationFrame (MessageComposer.tsx:413). toHaveText passes as
soon as the content lands, before that frame. If the frame lands
between fill()'s select-all and its typing, the selection collapses and
the text is appended. #8682's replaceComposerText handles this, but
these two call sites (:115 and :403) were missed.

Change: replaceComposerText at both.

workflow-local-controls.spec.ts:151, "inserts template variables with
keyboard control and restores the caret"

Runs: main 40926393897 and 40957616146 (before the fixes), and #8239,
which changes no desktop files. Signature: :194 expects "Keep {{auth"
and receives "Hello {{trigger.text}}Keep {{auth".

Cause: selectVariable restores the caret in a requestAnimationFrame
(WorkflowTemplateTextarea.tsx:66). Setting the controlled value has
already put the caret at the end, so the selectionStart poll passes
before that frame runs. The frame then collapses fill()'s select-all.

Change: after the poll, wait two animation frames, then fill. This is a
frame barrier, not a timer. The app queued its frame before the
evaluate, so it runs first.

Unit 8: clicks that hit the wrong element

profile.spec.ts:1128, "renders agent profile ingress subviews from the
Playwright mock bridge", failing at :1543

Run: #8523, which changes no desktop files. Its base (ef4130d)
already includes #8681. Signature: archiveAgentRow.click() times out
because three stacked Sonner success toasts (data-expanded="true")
intercept the pointer.

Cause: the test's Stop and Start steps raise success toasts
(useAgentLifecycleActions.ts:51, :61). Sonner stacks them bottom right,
over the profile panel's action rows, and pauses their dismissal while
the pointer is over them. #8681 fixed this test's motion window, and
fixed the toast problem only in agent-availability:602.

Change: press the five action rows (:1528-:1547) through a local
pressActionRow helper: toBeEnabled, focus, Enter, as
agent-availability.spec.ts:744-745 does. toBeEnabled keeps the wait
click() gave, since these rows are disabled while a delete or archive
is pending.

config-bridge-screenshots.spec.ts:314, "07 — profile side panel —
Configuration section"

Runs: main 40967147375 (before the fixes), and #8235, which changes no
desktop files. Signature: "Model" isn't visible within 10 s at :352.
The error context shows nadia's panel ("Not managed on this device"),
not Charlie's.

Cause: the spec clicked .last() of the rows with a
[data-testid^="message-avatar-"] descendant. That matches only
UserAvatar's -image and -fallback children. Radix renders the image
only once it loads, and each fallback only after its own 200 ms delayMs
timer (UserAvatar.tsx:67, :119). Neither avatar in #agents has a URL,
so the matching set fills in row by row as the timers fire. Resolved
in between, .last() is nadia's row (seeded at -85 s), just above
Charlie's "reporting in." row (-80 s). The note blamed a misplaced
click; the code points at locator resolution instead.

Change: pick Charlie's row by its text, as profile.spec.ts does for
Charlie's other #agents message. Then check the panel's name row reads
charlie before opening Runtime, so a wrong panel fails there and says
so.

Unit 9: the createWorkflow copies (preventive)

navigation.spec.ts:153, "direct workflow detail links close back to
workflows"

Runs: 7 failures on main, the last at 29 Oct 16:12Z, none after #8681.
Signature: the Edit workflow dialog has no "Trigger: Message Posted"
at :176. Runs: 14 first-attempt failures in Smoke shard 7, all passing
on retry; on main 39827234118, 40718018673, 40789486699, 40802051064,
40840949170, 40891643205 and 40909291143.

Cause: the same race #8681 fixed in workflows.spec's createWorkflow.
Picking a channel opens the trigger pane (WorkflowDialog.tsx:702), and
for Message Posted the conditions editor expands "Message text" by
default (WorkflowTriggerConditions.tsx:157), rendering an input with
that label. After "Send Message", AnimatePresence mode="wait"
(WorkflowFormBuilder.tsx:907) keeps the trigger body mounted through
its exit and holds the step body back. An unscoped
getByLabel("Message text") can fill the trigger's filter. The step then
has no text, which yamlToFormState rejects (workflowFormTypes.ts:348),
so the Edit dialog opens in YAML mode (WorkflowDialog.tsx:88). That is
the signature the earlier audit recorded for navigation:153.

Change: wait for the step inspector's "Action" header and "Step
details" control, then fill inside the inspector, as workflows.spec
does.

workflows.spec.ts:716 ("captures the built editor at desktop and
narrow widths", fill at :726) and :820 ("pane routes use stable IDs and
Form/YAML changes stay synchronized", fill at :831) pick a channel and
then add a Send Message step the same way, with no failures in the
window. So the wait moves into one addSendMessageStep helper in
tests/helpers/workflows.ts, rather than four copies. Both specs'
createWorkflow and those two tests call it. A change to the inspector's
structure then needs one fix.

Left out: workflow-reaction-picker.spec.ts:203-204. The note listed it,
but its trigger is Reaction Added, whose condition fields are "Reaction
emoji", "Author" and "Message" (workflowConditionExpression.ts:40). No
trigger control there is labelled "Message text", so the unscoped fill
can only reach the step's textarea.

Verification

Not run locally: Playwright bogs this machine down, and the Flake Check
lane will repeat the touched specs. pnpm typecheck and pnpm check pass.
biome's 3 warnings are in untouched files. An ad hoc tsc ran over the
eight touched specs, using the desktop tsconfig with only those files,
and over HEAD's copies of them. Both gave the same errors: onboarding
16, navigation 7, profile 7, workflow-local-controls 7,
projects-v3-screenshots 2, and none in mention-recipients,
config-bridge-screenshots or workflows. The messages are identical once
line numbers are stripped, and all are pre-existing (no Node types,
import.meta.env, MockBridgeOptions drift and the like). Once the helper
moved to tests/helpers/workflows.ts, a rerun over navigation,
workflows and the helper gave navigation's same 7 and none in the other
two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Oct 31, 2026
…kspace (#8744)

## Summary

Units 6–9 and 14 of the 23–30 Oct desktop Playwright flake audit, in two
commits. The specs now settle before they measure or act, and
remote-owned-mentions clears its draft reliably. Everything is
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### Unit 6: measuring before an animation finishes

Each spec now calls `waitForAnimations(page)` before measuring.

- `onboarding.spec.ts:1487` (#8715, Integration shard 1; the centring
check at :1545 receives 5.13): `showPage("owned")` slides content in
from `translateX(48px)` over 650 ms. The spec reads its three boxes in
separate `boundingBox` round trips, so they can come from different
points of the slide.
- `onboarding.spec.ts:2289` (main 40746677244 and 40925172215, and
#8666; the spacing check at :2485 receives 4.255): the avatar dialog has
a 200 ms zoom-in and a 250 ms height transition.
- `projects-v3-screenshots.spec.ts:280`, at :893 (main 39643531998,
40759209995 and 40969909906, plus four runs with fractional menu-edge
values): the context rail narrows from 380 to 280 px over 200 ms.

### Unit 7: `fill()` racing a caret move the app makes a frame later

- `mention-recipients.spec.ts:376` and `:97` (main 40969965981 and
40969858676): edit mode focuses the end of the composer in a
`requestAnimationFrame`. If that frame lands mid-`fill()`, the edit is
appended instead of replacing the text. **Change:** use #8682's
`replaceComposerText` at both call sites.
- `workflow-local-controls.spec.ts:151` (main 40926393897 and
40957616146, and #8239): `selectVariable` restores the caret in a
`requestAnimationFrame` after the spec's poll has passed. **Change:**
wait two animation frames before filling.

### Unit 8: clicks that hit the wrong element

- `profile.spec.ts:1128`, at :1543 (#8523): stacked Sonner success
toasts intercept the pointer. **Change:** a local `pressActionRow`
helper presses the five action rows with `toBeEnabled`, focus and Enter.
- `config-bridge-screenshots.spec.ts:314` (main 40967147375, and #8235):
`.last()` resolved while avatar fallbacks were still filling in on 200
ms timers, so it opened nadia's panel instead of Charlie's. **Change:**
pick Charlie's row by its text, and check the panel's name before
opening Runtime.

### Unit 9: the `createWorkflow` copies (preventive)

- `navigation.spec.ts:153` (7 failures on main, none since #8681; 14
first-attempt failures in Smoke shard 7): an unscoped
`getByLabel("Message text")` can fill the trigger's filter while
`AnimatePresence` keeps it mounted. **Change:** wait for the step
inspector, then fill inside it, as `workflows.spec` already does. The
wait now lives in one `addSendMessageStep` helper in
`tests/helpers/workflows.ts`, which `navigation.spec.ts` and
`workflows.spec.ts` (`createWorkflow`, :716, :820) all call.
- `workflow-reaction-picker.spec.ts:203-204` is left out. Its Reaction
Added trigger has no control labelled "Message text".

### Unit 14 (second commit): `remote-owned-mentions.spec.ts:566`

- **Run:** main 40998438103 (Smoke shard 9, job 123877347301). It passed
on retry and failed 1 of the 6 attempts it got in the window.
`sourceRecord()` at :673 returned thread A's draft instead of null.
- **Cause:** on ProseMirror, `fill("")` selects through the DOM, then
presses Delete. If ProseMirror hasn't seen the selection yet, it blocks
the Delete. Navigating to B then saves A's live text as its draft. This
is not a product bug: had the editor really been empty, the cleanup
would have cleared A's draft.
- **Change:** delete the text with one Backspace per character from the
end, then wait for the composer to read empty. The guard assertions are
unchanged.

### Related issue

None found. Sibling #8743 also edits `navigation.spec.ts`, in a separate
hunk. The two merge cleanly in either order. No open PR touches the
tests changed here. #6134, #8187, #5778, #8622, #7452, #7812, #8244 and
#3342 edit other tests in the same files.

### Testing

Not run locally, because Playwright bogs this machine down. Flake Check
will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass,
and biome's 3 warnings are in untouched files. Ad hoc `tsc` runs over
the touched specs and the helper found the same pre-existing errors as
on their `main` copies.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dr0uleau added a commit to block/buzz-app that referenced this pull request Nov 4, 2026
## Why

Saved task records need updates after restart without a full download on
each sync.

## What

Keep each local collection current through the relay's incremental
reads. Resume from the last saved checkpoint with a 120-second overlap.

## How

One window owns each collection. It saves paged changes and current-ID
reads in order. Explicit removals preserve saved bodies and preferences.
Channel removal commits at most 100 saved records at a time. Failed
pages cannot advance progress. Access recovery revalidates saved IDs.

Incremental reads use `artifacts.incremental` and `artifact:
"incremental"`. The existing WebSocket service keeps `artifacts.sync`
for a complete set followed by live updates. The two capabilities work
independently. Long retries wait 60–90 seconds so owners do not all
retry at the same fixed interval.

## Risk

The overlap assumes bounded database delay. Incremental reads cover
permanent channels. Native end-to-end sync needs the compatible relay
and client together.

## Testing

At `09ee7d13d`, native macOS tests use the real Buzz relay. A host
collection saves bootstrap data, resumes from its checkpoint after app
restart, and reads a later update after a live hint. Channel removal
returns an explicit `remove`; the saved body and pinned preference
remain, with unavailable status.

The relay rejects an invalid initial checkpoint with HTTP 400. The
client makes one fresh pass. An injected failure of that fresh pass
preserves the prior checkpoint, body, and preference.

A real policy change returns HTTP 409 with `policy_changed`. Recovery
loads 101 records in pages of 100 and one, then rechecks saved IDs in
batches of 100 and one. The two delta reads take 156 ms and 50 ms. All
101 records remain saved, with a checkpoint and no refresh error. This
verifies the page boundary; large-scale performance remains unmeasured.

Human acceptance passes in the native test harness: Publish test update
reaches the saved collection and preserves the pinned preference. The
operator supplies the PASS screenshot. The harness uses the real host
service and signed reader. It does not establish adoption by a
production plugin.

Chromium and WebKit also pass the two ownership-transfer cases with real
IndexedDB and Web Locks. Command: `bin/pnpm exec playwright test
--config tests/browser/playwright.config.mjs
tests/browser/artifact-sync.spec.mjs --project chromium --project webkit
--no-deps`.

buzz-review-completed

## Bigger picture

The storage prerequisite, [PR
1501](#1501), is merged into
`main`. The relay prerequisite is [PR
8523](block/buzz#8523). The client preserves the
WebSocket service from [PR
1523](#1523).

Generated with Codex

---------

Signed-off-by: Daniel Rouleau <drouleau@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
TheSentinel454 added a commit that referenced this pull request Nov 5, 2026
)

## Problem

Migrations `0076`, `0078` and `0081` raise an exception on any table
with at least one row unless its index was prebuilt with `CREATE INDEX
CONCURRENTLY`. A relay that runs migrations at startup
(`BUZZ_AUTO_MIGRATE`, on by default in the Helm chart) and has data in
those tables therefore crash-loops on upgrade:

```
migration error: while executing migration 81: error returned from database:
prebuild idx_nip_fi_profile_heads_names concurrently; see docs/profile-search.md
```

`0081` (#9019) hits every auto-migrating relay that has published NIP-FI
verified profiles. `0076`/`0078` (#8523) hit any relay with artifact
data or channel members. Each of these tables is small enough on such
relays that a plain index build takes milliseconds.

The root cause is `docs/database.md` Rule 6. It told authors that an
index on a populated table MUST be built concurrently by an operator
ahead of time, so migrations were written to refuse populated tables.
With auto-migration that refusal is a crash loop, not a safe stop.

## Fix

The migrations are already applied on some databases, so they can't be
edited: sqlx would reject the changed checksums. Instead,
`run_migrations_locked` builds their missing indexes first. For each
entry in `INLINE_INDEX_BUILDS` (`0076`, `0078`, `0081`), it runs
migrations through the version before it, then builds any missing index.

Each build is a plain `CREATE INDEX` in its own transaction, under the
same 1s `lock_timeout` and 5s `statement_timeout` the migrations use.
That is the bounded inline build `0049` already does:

- **Small populated databases** upgrade the same way empty ones do.
- **Large databases** still fail closed. Only a lock or statement
timeout (`55P03` / `57014`) gets the "prebuild concurrently" hint,
pointing at that migration's own doc. Other errors pass through
unchanged. The blocked migration is not recorded.
- **Prebuilt indexes** are found with `to_regclass` and skipped before
`CREATE INDEX`, so deployments that prebuilt concurrently never request
the writer-conflicting lock.
- **Invalid prebuilt indexes** are left for the migration to reject, as
before.

`CONCURRENTLY` is not used in this path. The runner holds the migration
advisory lock, and another replica blocked on that lock holds a snapshot
that a concurrent build would wait on.

### Rules

- `docs/database.md` Rule 6: with `BUZZ_AUTO_MIGRATE` on, every
migration must apply its own change on a populated database and must
never refuse one because it has rows. A new index is built inline under
the migration's budgets. Only deployments that apply migrations out of
band (auto-migration off) prebuild concurrently.
- Root `AGENTS.md` (step 4) and `migrations/AGENTS.md` repeat the
never-refuse rule.
- `docs/artifact-sync-deployment.md` and `docs/profile-search.md`
describe the startup behavior first and the concurrent prebuild as the
out-of-band path.

## Tests

- `populated_database_auto_migrates_through_every_migration` (Postgres)
migrates to `0075`, seeds artifact rows, and checks four phases:
1. **Lock held:** while a writer lock is held, the `0076` build fails
closed.
2. **Index prebuilt:** with the `0076` index prebuilt, the build is
skipped.
3. **Partial `0078`:** a blocked `0078` build keeps the builds before it
committed.
4. **Verified profile seeded:** with a verified profile head inserted
before `0081`, a final startup succeeds. It asserts that the applied
versions equal every embedded migration and that all six indexes are
valid, ready and live.
- `frozen_prebuild_guards_have_inline_builds` (no database) scans
`migrations/`. It fails if any migration refuses a populated table
(`RAISE EXCEPTION 'prebuild …`) without an inline build, and if a new
guard is added.
- `migration_execution_cannot_bypass_schema_destruction_lock` allows
exactly one production `run_to`, inside `run_migrations_locked`.

## What got simpler

- Auto-migrating relays no longer need a manual, operator-only prebuild
step for small deployments. That restores the chart README's contract
that `helm upgrade` is the whole upgrade procedure.
- Rule 6 no longer steers authors toward refusing populated tables, and
a test enforces that.
- The change adds one table-driven step for three frozen migrations and
no new configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
codex-review — f3d6ac85 Deployed Oct 30, 2026 by dr0uleau via Run Codex Security Review #10822
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants