Skip to content

feat(state): R2-backed ledger/v1 + assets blob transport, migration workflow, and opt-in dual-read (phase 3 foundation) - #899

Merged
steipete merged 2 commits into
mainfrom
feat/phase3-r2-ledger-assets
Jul 27, 2026
Merged

feat(state): R2-backed ledger/v1 + assets blob transport, migration workflow, and opt-in dual-read (phase 3 foundation)#899
steipete merged 2 commits into
mainfrom
feat/phase3-r2-ledger-assets

Conversation

@steipete

Copy link
Copy Markdown
Contributor

Phase 3 foundation of the Cloudflare-canonical migration: move the last two git-state consumers — the immutable ledger/v1 tree (~315 MB) and the assets tree (~52 MB) — onto R2 via the Worker, so the git state lane can be deleted in a follow-up. This PR adds the transport, migration tooling, and an opt-in dual-read; defaults stay git and no git-lane code is removed.

Worker endpoints (dashboard/state-blobs.ts, routed in dashboard/worker.ts)

New HMAC-authenticated routes under /internal/state/blobs/*, using the same CLAWSWEEPER_WEBHOOK_SECRET signature scheme as the existing /internal/state/records/* routes, backed by the existing STATE_SNAPSHOTS R2 binding (bucket clawsweeper-state-snapshots) under the distinct key prefixes ledger/v1/... and assets/... (phase-2 snapshots live under <repoSlug>/<revision>/..., so no collisions):

  • put — single-shot base64 upload (≤24 MiB) with server-side sha256 verification before the object is written.
  • multipart/start|part|complete|abort — chunked upload for larger blobs (8 MiB parts); the claimed digest is stored as client-verified metadata and read back by the migration tooling.
  • stat, list (cursor-paged, digests included), and chunk — 32 MiB range-proxied downloads mirroring the phase-2 snapshot chunk endpoint (R2 bindings cannot presign).
  • Immutability: ledger/... keys are create-only — a PUT over an existing key with a different digest is a 409 ledger_blob_immutable_conflict; a same-digest PUT is an idempotent no-op. assets/... may overwrite. The multipart path re-checks at completion and aborts on conflict.

Client (scripts/worker-blobs.ts)

Upload/download/list/materialize helpers on the existing signedPost/signedRequest retry machinery from scripts/worker-records.ts (bounded 5xx/network retries). materializeStateBlobs rebuilds the ledger/ + assets/ trees into a worktree with a content-addressed digest cache (CLAWSWEEPER_BLOBS_CACHE_DIR), staged + atomically renamed, and refuses cutover when the store is empty or unbound.

Migration (scripts/migrate-state-blobs.ts + .github/workflows/migrate-state-blobs.yml)

Cursor-resumable dispatch workflow (patterned on backfill-worker-records.yml): sparse-checks-out ledger/assets from the state repo and uploads every file with digest verification (single-shot uploads are server-verified; multipart uploads are re-downloaded and hashed — --verify all re-downloads everything). Idempotent: re-runs report unchanged; a locally diverged immutable ledger file fails loudly with the 409. Prints a JSON summary (files/uploaded/unchanged/verified/bytesUploaded/cursor/nextCursor).

Usage: dispatch Migrate state blobs to R2 with trees=both|ledger|assets, optional cursor/max_files to resume a bounded run.

Dual-read shim (scripts/hydrate-state.ts)

CLAWSWEEPER_LEDGER_SOURCE=worker (or --ledger-source worker) hydrates ledger/ + assets/ from R2 instead of the git checkout, with a loud WORKER LEDGER CUTOVER REFUSED … FALLING BACK TO GIT fallback when the blob store is unbound or unseeded. Deny-by-default: unset ⇒ git, byte-identical to today for every existing caller.

Tests

test/worker-state-blobs.test.ts drives the real Worker fetch handler end-to-end through the client helpers against an in-memory R2 fake: endpoint auth + fail-closed 503, server-side digest rejection, ledger immutability (incl. the start/complete multipart race), asset overwrite, multipart round-trip, chunk range validation, client retry on transient 502s, cursor-paged listing, migration idempotency/resume/divergence, and hydrate dual-read selection incl. both fallback reasons.

Not in this PR / follow-ups

  • Worker deploy is required before the endpoints exist in production (wrangler deploy from dashboard/).
  • Run the migration workflow to seed R2, then flip CLAWSWEEPER_LEDGER_SOURCE=worker per consumer.
  • Ledger/asset writer paths still publish via git; pointing them at the blob endpoints, deleting the git lane (git-publish.ts lease machinery, publish workflows), and freezing the state repo remain the phase-3 cutover work.

…tion workflow and opt-in dual-read (phase 3 foundation)

Move the last two git-state consumers toward R2 (Cloudflare-canonical
phase 3), reusing the STATE_SNAPSHOTS bucket under distinct ledger/v1/
and assets/ prefixes:

- Worker: HMAC-authenticated /internal/state/blobs/* endpoints
  (put, stat, chunk, list, multipart/*) with server-side sha256
  verification on single-shot puts, 32MiB range-proxied downloads,
  and create-only immutability for ledger keys (409 on divergent
  overwrite, idempotent same-digest re-put; assets may overwrite).
- Client: scripts/worker-blobs.ts upload/download/list/materialize
  helpers on the existing signedPost/signedRequest retry machinery,
  with a content-addressed digest cache for CI hydration.
- Migration: cursor-resumable scripts/migrate-state-blobs.ts plus the
  dispatchable migrate-state-blobs.yml workflow with digest
  verification and a JSON summary.
- Dual-read: hydrate-state honors CLAWSWEEPER_LEDGER_SOURCE=worker
  (deny-by-default git) with a loud git fallback when the blob store
  is unbound or unseeded.

No git-lane code is removed and the Worker still needs a deploy before
the endpoints exist in production.
@steipete
steipete requested a review from a team as a code owner July 27, 2026 19:08
Comment thread dashboard/state-blobs.ts
}

function blobJson(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
@clawsweeper clawsweeper Bot added the rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. label Jul 27, 2026
…on gate boundary

The repo-wide invariant hard-counts setup-state checkouts (30) so new
state checkouts must consciously join the coordinator boundary; the new
migrate-state-blobs workflow already carries the gate expressions, so
admit it by bumping the count to 31.
@clawsweeper

clawsweeper Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper status: review started.

I am starting a fresh review of this pull request: feat(state): R2-backed ledger/v1 + assets blob transport, migration workflow, and opt-in dual-read (phase 3 foundation) This is item 1/1 in the current shard. Shard 0/1.

This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking.

Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants