Commit Graph
11 Commits
Author SHA1 Message Date
Omar Sobh 523b22f148 Phase 5j: Prometheus /metrics endpoint
Adds a tiny axum-served HTTP endpoint that exposes the same
CacheMetrics counters that back GetMetrics + gossip, in Prometheus
text exposition format (v0.0.4). Enable per node by setting
`cluster.prom_bind` in the daemon config.

* metrics.rs: MetricsReply::to_prometheus() emits one HELP + TYPE +
  sample line per counter. started_unix is a gauge; everything else is
  a counter. Preallocates ~1 KiB so no reallocs mid-format.
* prom.rs (new): PromServer::bind spins up axum on a TcpListener,
  serves GET /metrics, returns 404 elsewhere. Graceful shutdown via
  oneshot channel; abort() variant for the sync-drop path in
  ClusterServices. Snapshots on every scrape (no cache) — Relaxed
  atomic loads are cheap enough that even 1 Hz is sub-microsecond.
* config.rs: new optional `cluster.prom_bind: SocketAddr` field.
  Default None means no server; typical value is 127.0.0.1:7702.
* services.rs: wires PromServer into ClusterServices when both a
  router and prom_bind exist. Warns (doesn't fail) if prom_bind is set
  without RPC — nothing would ever change on a scrape.

+8 tests:
- metrics: to_prometheus emits every counter with correct type; zeros
  still produce valid exposition (fresh daemon scrape)
- prom: content-type is text/plain; version=0.0.4; live updates
  between requests (no cache); unknown paths 404; shutdown stops
  serving
- services: end-to-end scrape returns router-driven counters;
  prom_addr() is None when unconfigured (no accidentally-leaked port)

Raw-TCP HTTP client in tests instead of pulling in reqwest — 30 lines
of tokio::net + string split for GET / read-to-close is small enough
to justify not adding a dep.

249 tests pass (+8 from Phase 5i). Pre-existing macOS `du -sb` failure
unchanged.
2026-07-12 04:36:34 -07:00
Omar Sobh d36cec11a6 Phase 5g: cache metrics + GetMetrics RPC + peer-metrics CLI
Every RPC handler that answers a hit-or-miss question now increments
lock-free atomic counters. The GetMetrics RPC (0x13) returns a JSON
snapshot of every counter; the new claw-cargo peer-metrics CLI
prints hit rates, byte volumes, and counter uptime.

Placement engines can now poll these across the fleet to bias runner
scheduling toward whichever node has the warmest cache for a given
repo/tag combination.

## New module: cluster/metrics.rs (268 lines)

Types:
- CacheMetrics — atomic counters, all AtomicU64, Relaxed ordering
  (metrics are advisory, not consistency-critical)
- MetricsReply — JSON snapshot returned by GetMetrics

Public API:
- CacheMetrics::new() — timestamped start, all counters at 0
- record_get_ref_hit / _miss
- record_get_tag_hit / _miss
- record_blob_get_bytes / record_blob_put_bytes
- record_get_chunk_hit / _miss
- record_has_chunk_hit / _miss
- snapshot() — atomic-load every field into a MetricsReply

MetricsReply derived helpers:
- get_ref_hit_rate() / get_tag_hit_rate() / has_chunk_hit_rate() —
  Option<f64> so 0/0 returns None instead of NaN

## RPC method

- GetMetrics (0x13): payload = empty; reply = JSON MetricsReply

Wire-level instrumentation added to RpcRouter dispatch:
- GetRef  → record_get_ref_hit / _miss
- GetTag  → record_get_tag_hit / _miss
- BlobGet → record_blob_get_bytes (on hit)
- BlobPut → record_blob_put_bytes
- HasChunk → record_has_chunk_hit / _miss
- GetChunk → record_get_chunk_hit + record_blob_get_bytes on hit
             / record_get_chunk_miss

RpcRouter grows Arc<CacheMetrics> unconditionally — every router has
metrics, so a fresh node with no traffic still returns a valid
snapshot with all zeros + started_unix.

Streaming variants (BlobPutStream / BlobGetStream) don't yet track
byte counts — they'd require plumbing the count out of put_stream /
stream_to. Follow-on if it turns out to matter for placement.

## Client helper + CLI

- call_get_metrics(&conn) → Result<MetricsReply>

- claw-cargo peer-metrics [--peer ...] [--peer-addr ...] [--tls-dir ...]
    Fetches + pretty-prints:
      counter uptime:      42s
      GetRef  hits/misses: 123 / 45
              hit rate:    73.21%
      GetTag  hits/misses: 8 / 2
              hit rate:    80.00%
      HasChunk hits/miss:  512 / 88
               hit rate:   85.33%
      GetChunk hits/miss:  47 / 12
      Blob GET bytes:      1.23 GiB
      Blob PUT bytes:      3.45 GiB

human_bytes() helper picks GiB / MiB / KiB / B based on magnitude.

Subcommand count now 9: build / prefetch / status / fingerprint /
pin / unpin / list-tags / prewarm / peer-metrics.

## Tests (13 new, all real filesystem / real QUIC — no mocks)

CacheMetrics (6):
- new_starts_all_counters_at_zero_except_timestamp
- recorders_increment_the_right_field (every recorder × 1-2 counts)
- hit_rates_none_when_zero_events (avoids 0/0 NaN)
- hit_rates_compute_correctly (3 hits / 1 miss → 75%)
- snapshot_round_trips_through_json
- snapshots_across_threads_are_consistent_up_to_relaxed_ordering
  (8 threads × 1000 increments → exactly 8000)

RPC integration (7):
- phase_5g_method_byte_encoding
- get_metrics_returns_empty_snapshot_before_any_activity
- get_ref_records_hit_and_miss_counters (2 hits + 1 miss)
- get_tag_records_hit_and_miss_counters
- blob_get_and_blob_put_record_byte_counts
- has_chunk_and_get_chunk_record_hit_miss_counters
- **end_to_end_get_metrics_over_real_quic** — seed activity locally,
  fire GetRef/GetTag/BlobGet dispatches to move the counters, then
  fetch metrics through real QUIC + mTLS and verify each field
  including the 0.5 hit rate calculation

238 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/metrics.rs: 268
- cluster/rpc.rs: 818
- cluster/rpc/tests_phase5.rs: 905
- claw_cargo.rs: 894

## What this enables

Fleet-wide visibility into which peer is actually serving traffic:

  # From anywhere with connectivity + fleet mTLS
  claw-cargo peer-metrics --peer tank
  claw-cargo peer-metrics --peer architect
  claw-cargo peer-metrics --peer morpheus

Compare hit rates side by side to see which node's cache is warmest.
A placement engine can automate this — poll every 30s, feed the
scheduler.

## Follow-on

- 5h: streaming variant of prewarm (fixed-memory ceiling for many-GB
  blobs)
- 5i: metrics also published via gossip so PeerView carries hit rate
  without a per-peer GetMetrics roundtrip
- 5j: prometheus /metrics endpoint on the daemon for existing dash
  integrations
- 6: FUSE mount for warm-tier git worktrees
2026-07-12 04:06:17 -07:00
Omar Sobh b7904b59a5 Phase 5d: named tags + pin/unpin/list-tags CLI
Human-readable pins on top of the raw 32-byte ref layer. Operators
publish `clawverse:main:latest-cache` → BlobId once, then everything
downstream (CI runners, dev laptops) references the tag instead of
passing 64-char hex hashes around.

## Module: cluster/tags.rs (433 lines)

TagStore for string-key → 32-byte-value:

- open(root) — creates layout, safe on existing stores
- put(key, value) / get(key) / delete(key) / contains(key)
- list() — sorted by key
- Atomic writes via tempfile + rename
- Key length capped at MAX_TAG_KEY_BYTES (4 KiB); empty keys rejected

On-disk record: `key_len:u16 (LE) || key_bytes || value:32bytes`.
Filename is `blake3(key)` hex so arbitrary UTF-8 keys land at
deterministic paths without shell escaping.

TagEntry type (public, serde) for list results:
`{ key, value_hex }`. Includes `decode_value() → Result<[u8;32]>`.

## RPC methods

- PutTag (0x0f):  payload = encoded record → STREAM_STATUS_OK / err
- GetTag (0x10):  payload = key bytes → 32-byte value / NotFound
- DeleteTag (0x11): payload = key bytes → STREAM_STATUS_OK / NotFound
- ListTags (0x12): payload = empty → JSON Vec<TagEntry>

RpcRouter grows optional Arc<TagStore> via `.with_tag_store(store)`.

## Services + config

ClusterServices auto-opens a TagStore at `<blob_store_root>/tags-db`
alongside the ref store. `tag_store` field on ClusterServices, same
enable-with-blob-store semantics.

## claw-cargo new subcommands

- `claw-cargo pin --name clawverse:main:latest`
    Compute current fingerprint → look up its BlobId via GetRef →
    publish TagStore mapping. Errors cleanly if the fingerprint
    hasn't been built yet (nothing to point at).

- `claw-cargo unpin --name clawverse:main:latest`
    Delete the tag. Prints "no such tag" if it wasn't set.

- `claw-cargo list-tags`
    Print every tag with its 32-byte hex value.

Total subcommand count now 7: build / prefetch / status / fingerprint
/ pin / unpin / list-tags. All share the layered config from Phase 5c.

## Client helpers

- call_put_tag / call_get_tag / call_delete_tag / call_list_tags
- All follow the same error-mapping conventions as prior client helpers
  (NotFound → Ok(None) or Ok(false), everything else → Err)

## Housekeeping

rpc.rs was pushing past the 1300-line ceiling with the tag methods
added. Client helpers moved to `cluster/rpc/client.rs` with a
re-export (`pub use client::*;`) so external callers still write
`cluster::rpc::call_*`. Result:

- rpc.rs: 773 (was 1343)
- rpc/client.rs: 593 (new)
- rpc/tests.rs: 1235
- All under ceiling.

## Tests (33 new, all real filesystem / real QUIC — no mocks)

TagStore (16 in cluster/tags.rs):
- open_creates_layout
- get_returns_none_for_missing (+ contains false)
- put_and_get_round_trip
- put_overwrites_prior_value
- delete_returns_true_for_existing_and_false_for_missing
- put_rejects_empty_key
- put_rejects_oversize_key
- list_returns_all_tags_sorted
- list_is_empty_on_fresh_store
- keys_with_slashes_and_colons_round_trip (real-world tag shape)
- encode_and_decode_round_trip (raw wire format)
- decode_rejects_short_record
- decode_rejects_length_mismatch
- decode_rejects_non_utf8_key
- tag_entry_decode_value_round_trip
- tag_entry_decode_value_rejects_bad_hex

RPC dispatch (7 new):
- phase_5d_method_byte_encoding
- tag_rpcs_return_not_configured_without_store
- put_tag_stores_and_get_tag_reads_back
- get_tag_returns_not_found_for_missing
- get_tag_rejects_empty_key
- delete_tag_removes_and_returns_not_found_after
- list_tags_returns_json_sorted

End-to-end over real QUIC (1):
- **end_to_end_pin_lookup_delete_over_real_quic** — publish tag →
  look up → list → delete → confirm gone. Full round trip through
  the wire layer including JSON deserialization of the list.

Also 8 downstream tests continued passing after the client.rs split
(no test moved, they were untouched).

223 tests pass. Pre-existing macOS-only failure unchanged.

## What this enables

Operator flow:

  # Build once on the primary
  $ claw-cargo build
  → cache MISS → cargo build (50 min) → capture + upload
  → summary: fingerprint 4a3b…, blob 8c2f…, uploaded 3.2 GiB

  # Publish a friendly name
  $ claw-cargo pin --name clawverse:main:2026-07-12
  pinned:      clawverse:main:2026-07-12
  fingerprint: 4a3b2c…
  blob:        8c2f1a…

  # Anyone else can now find it via list-tags
  $ claw-cargo list-tags
  clawverse:main:2026-07-12    8c2f1a…
  clawverse:main:latest         8c2f1a…

  # CI runner sees the same fingerprint in its workspace state, hits
  # the ref directly via GetRef — the tag is for operator visibility

## Follow-on

- 5e: prefetch --pin <tag> — bypass fingerprint compute, download
  the tagged BlobId directly (useful when you want an old cache to
  test regression scenarios)
- 5f: gitea webhook pre-fetch — daemon pre-warms cache for known
  fingerprints before CI runner starts
- 3: full CRDT metadata layer (namespaces, versioned pointers,
  vector clocks) if the plain-tag model turns out to have
  real-world conflict scenarios
2026-07-12 00:07:33 -07:00
Omar Sobh 57d8358255 Phase 5c: claw-cargo UX — config files + status + prefetch
Ships the last-mile ergonomics that make claw-cargo actually usable
day-to-day: layered config files so you don't retype --peer-addr on
every invocation, plus two lightweight subcommands (status +
prefetch) for the "what's in the cache" and "warm my target dir"
workflows respectively.

## Config precedence

Later wins:

  1. Built-in defaults (profile=dev, features=[])
  2. ~/.claw-cargo/config.toml       (per-user defaults)
  3. <workspace>/.claw-cargo.toml    (per-repo overrides)
  4. CLI flags                        (per-invocation overrides)

Shape:

  [peer]
  name = "tank"
  addr = "10.0.0.14:7702"
  tls_dir = "/etc/claw-store/tls"

  [build]
  profile = "release"
  features = ["a", "b"]

## New module: cluster/client_config.rs (496 lines)

- ClientConfig / PeerSection / BuildSection — TOML-serialisable, all
  Option<> fields at every layer so partial configs are legal
- ClientConfig::from_toml_str / from_file_or_default (missing file →
  default, not error)
- ClientConfig::merge — Option::Some in `other` wins over `self`
- ClientConfig::load_layered(workspace) — user → workspace
- ResolvedClientConfig — final flattened shape after CLI overrides,
  with require_peer_name / require_peer_addr / require_tls_dir /
  require_peer_bundle helpers that produce a specific error message
  instead of "some Option was None"
- write_config_file — for tests + future `claw-cargo config init`

Ships with 11 unit tests including a load_layered test that fakes
HOME + workspace via a tempdir, writes both configs, verifies the
workspace override takes precedence.

## claw-cargo (rewritten to 469 lines)

Four subcommands with layered config:

  claw-cargo fingerprint [--profile ...] [--features ...] [--workspace ...]
    → local-only, no network
  claw-cargo status <peer args>
    → connect + GetRef + BlobStat, print hit/miss + size, no download
  claw-cargo prefetch <peer args>
    → hit → BlobGetStream + restore_target, no cargo
  claw-cargo build <peer args> [-- extra cargo args]
    → same as Phase 5b flow, now with layered config for peer args

Refactored internals:
- setup_local / setup_peer — figure out workspace, load config,
  resolve CLI overrides, compute fingerprint
- connect_peer — load NodeIdentity, open QUIC connection
- peer_lookup — GetRef → BlobStat, handle the "ref points at a
  garbage-collected blob" case as a miss

## Live smoke test

Verified end-to-end on this workspace:

  # No config file → built-in defaults
  $ claw-cargo fingerprint
  profile: dev, features: (none), fingerprint: 8ee4cf…

  # Add .claw-cargo.toml with profile=release + features=some-feature
  $ claw-cargo fingerprint
  profile: release, features: some-feature, fingerprint: 2dfcb1…

  # CLI overrides just the profile; features fall through from config
  $ claw-cargo fingerprint --profile dev
  profile: dev, features: some-feature, fingerprint: 84a756…

  # `status` without peer args → clean validation error
  $ claw-cargo status
  Error: peer.name not set (config file or --peer)

## Tests (11 new, all real filesystem — no mocks)

- from_toml_str_parses_full_config
- from_toml_str_handles_partial_sections (peer.name only)
- from_file_or_default_returns_default_when_missing
- merge_prefers_later_over_earlier (unset fields fall through)
- resolve_applies_cli_overrides_over_layered
- resolve_falls_through_to_default_profile_when_unset_everywhere
- require_peer_bundle_errors_when_incomplete (specific error text)
- validate_peer_errors_on_missing_field
- load_layered_reads_both_files — fake HOME + workspace, verifies
  workspace override takes precedence
- user_config_path_uses_home
- write_and_read_round_trip_via_disk (nested dir creation)

199 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (well under 1300-line ceiling):
- cluster/client_config.rs: 496
- claw_cargo.rs: 469

## What's next

The CLI is now usable day-to-day. Realistic next steps:

- 5d: publish cache hit/miss metrics into gossip so the placement
  engine can bias runner scheduling toward warm nodes
- 5e: pre-fetch on Gitea webhook — daemon receives a "PR opened for
  fingerprint X" hint and warms the local cache before the runner
  even starts pulling
- 3: CRDT metadata for human-readable pins (`clawverse:main:latest`
  → fingerprint hex) so operators can pin cache versions without
  passing raw hashes around
- 6: FUSE mount so `~/projects/clawverse` on any node is transparently
  the tank-hosted canonical warm-tier copy
2026-07-11 23:45:04 -07:00
Omar Sobh 2d09b4687c Phase 5b: KV refs + claw-cargo CLI (the killer feature, live)
Ships the actual user-facing cargo build cache. Combined with Phase 5a
(fingerprint + capture + restore) + the whole Phase 2 blob substrate,
`claw-cargo build` now runs `cargo build` with a peer-cache lookup:
hit → download+restore, miss → build+capture+upload.

## What ships

### cluster/refs.rs (243 lines)

A dumb 32-byte-key → 32-byte-value directory-backed store. Used to map
fingerprints → BlobIds. Layout mirrors BlobStore:

  <root>/
    refs/<kk>/<key_hex>.ref     — 32 raw bytes
    .tmp/                        — atomic-rename staging

Public API: RefStore::open / get / put / delete / contains. All writes
atomic via tempfile + rename. Deliberately no versioning or CRDT
semantics — that's Phase 3. Every real cargo-cache lookup is a
single-key-single-value shape.

### New RPC methods

- GetRef (0x0d): payload = 32-byte RefKey; reply = 32 bytes / NotFound
- PutRef (0x0e): payload = 32-byte RefKey || 32-byte RefValue;
  reply = STREAM_STATUS_OK / error

### RpcRouter + services

- RpcRouter grows optional Arc<RefStore> via `with_ref_store`
- ClusterServices opens a RefStore alongside the BlobStore when
  `blob_store_root` is configured (co-located at `<blob_root>/refs-db`)
- `blob_store_enabled()` / `ref_store_enabled()` introspection

### claw-cargo binary (319 lines)

New bin target `claw-cargo` — thin CLI wrapping the whole stack:

  claw-cargo fingerprint --profile release --features "a,b"
    → prints the workspace fingerprint (no network)

  claw-cargo build \
    --peer <name> --peer-addr <ip:port> --tls-dir <dir> \
    --profile release --features "a,b" \
    -- --workspace=x --frozen ...
    → 1. compute fingerprint
      2. QUIC + mTLS connect to peer
      3. GetRef(fingerprint) → BlobId?
         HIT: BlobStat → BlobGetStream → restore_target → cargo build
         MISS: cargo build → capture_target → BlobPutStream → PutRef
      4. Print summary: fingerprint, hit/miss, bytes, cargo elapsed

## Live smoke test

Ran claw-cargo fingerprint on this workspace with three profile/feature
combos — got three distinct 32-byte fingerprints. Same profile+features
on the same workspace state → same fingerprint (Phase 5a's guarantee
carried through the CLI).

## Tests (14 new, all real — no mocks)

Refs store (7):
- open creates layout
- get returns None for missing
- put + get round-trips
- put overwrites prior value
- delete removes ref + reports (false on second delete)
- distinct keys produce distinct on-disk files (bucket fan-out proof)
- rejects_wrong_length_on_disk (corruption detection)

RPC (7):
- phase_5b_method_byte_encoding
- get_ref_returns_not_found_for_missing
- put_ref_stores_and_get_ref_reads_back
- put_ref_rejects_wrong_length_payload
- get_ref_rejects_wrong_length_payload
- ref_rpcs_return_not_configured_without_store
- end_to_end_put_ref_get_ref_over_real_quic — full 2-node QUIC + mTLS
  round trip proving PutRef/GetRef work at the wire level

188 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/refs.rs: 243
- cluster/rpc.rs: 1169
- cluster/rpc/tests.rs: 1073
- cluster/services.rs: 565
- claw_cargo.rs: 319

## Where this leaves us

The distributed FS + cargo cache is functionally complete for the
happy path:

  Node A builds clawverse for the first time
  → cargo build (50 min cold)
  → capture_target (a few seconds)
  → push to node B via BlobPutStream (network-bound)
  → PutRef(fingerprint → BlobId)

  Node B on the same workspace state runs `claw-cargo build …`
  → compute_fingerprint (ms)
  → GetRef → hit
  → BlobGetStream (network-bound)
  → restore_target (a few seconds)
  → cargo build → sees valid deps/.fingerprint, builds only
    workspace crates (~3 min instead of 50)

Same workspace state on a third machine? Same fingerprint → same
cache hit. That's the whole design.

## Follow-on

- Phase 5c: pre-fetch on Gitea webhook so CI runners never wait
- Phase 5d: metric ticker publishes cache hit rate into gossip so
  the placement engine can bias runner scheduling toward warm nodes
- Phase 3: CRDT metadata for human-readable pins on top of raw
  32-byte refs (`clawverse:main:latest-cache` → fingerprint hex)
- Phase 6+: FUSE mount for the warm-tier git worktrees
2026-07-11 23:36:37 -07:00
Omar Sobh 242ef527b4 Phase 5a: fingerprint + capture + restore for build-artifact cache
The substrate for the killer feature. Given a cargo workspace, compute
a deterministic 32-byte BLAKE3 fingerprint over the inputs that
determine what artifacts should be produced, then bundle the portable
subset of `target/<profile>/` into a zstd-compressed tarball ready to
hand to BlobStore.

## Module: cluster/build_cache.rs (661 lines)

Types:
- FingerprintInputs { cargo_lock, rustc_version_verbose, cargo_config,
  rust_toolchain, profile, features, rustflags, target_triple }
- Fingerprint(32 bytes) — parallel shape to BlobId

Public API:
- FingerprintInputs::collect(workspace, profile, features) — reads
  Cargo.lock, shells out to `rustc --version --verbose`, reads
  optional config files, extracts host triple, sorts+dedups features
- FingerprintInputs::compute() → Fingerprint — domain-separated
  BLAKE3 with per-field labels + null separators so field boundaries
  can't collide
- capture_target(target_dir) → zstd-tarball bytes
- restore_target(bytes, target_dir) → unpacks
- capture_workspace(workspace, profile) — resolves target dir
- compute_workspace_fingerprint(workspace, profile, features) —
  returns (inputs, fingerprint)

Captured: deps/, .fingerprint/, build/, examples/, plus small
top-level files (.cargo-lock, .rustc_info.json, CACHEDIR.TAG).
Explicitly NOT captured: incremental/ (per-machine, not portable —
tied to absolute paths + rustc state; restoring on another host
silently corrupts the build).

Determinism guarantees:
- HeaderMode::Deterministic on the tar builder — identical trees
  produce byte-identical tarballs (proven by
  `capture_yields_identical_bytes_for_identical_input`)
- follow_symlinks(false) — symlinks archived as symlinks, not their
  targets, so the fingerprint doesn't drift with symlink destinations
- Features sorted + deduped so `["b","a"]` and `["a","b"]` hash the same
- Missing optional files treated as empty strings so `absent ==
  empty` (add-then-remove doesn't churn the hash)

Deps added: tar 0.4, zstd 0.13.

## Tests (18 new, all real filesystem — no mocks)

Fingerprint (7):
- fingerprint_is_deterministic
- fingerprint_changes_when_cargo_lock_changes
- fingerprint_changes_when_profile_changes
- fingerprint_changes_when_features_change
- fingerprint_is_feature_order_independent (proves sort semantics)
- fingerprint_domain_separation_prevents_field_collision — swap
  content between two string fields; naive concat hasher would
  collide, ours doesn't
- fingerprint_hex_length_and_stability

Input collection (4):
- read_optional_returns_empty_for_missing
- read_optional_returns_content_for_existing
- collect_errors_when_cargo_lock_absent
- collect_reads_cargo_lock_and_computes — actually shells out to
  `rustc --version --verbose`, verifies triple extraction

Capture/restore (5):
- capture_target_errors_when_dir_missing
- capture_and_restore_round_trip_preserves_files — full 6-file
  tree including a captured example + an intentionally-excluded
  incremental/ dir; verifies incremental/ is absent after restore
- capture_yields_identical_bytes_for_identical_input — byte-equal
  tarballs across two identical source trees
- capture_skips_top_level_files_not_in_allowlist
- capture_workspace_resolves_profile_dir

End-to-end (1):
- **end_to_end_fingerprint_capture_blob_restore** — the full
  workspace → fingerprint → capture → BlobStore::put_bytes →
  BlobStore::get_bytes → restore_target loop. Proves the whole
  round-trip lands byte-equal for both captured file trees, and
  the BlobId is deterministic across runs. This is the primitive
  the cargo-cache CLI sits on top of.

174 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (well under 1300-line ceiling):
- cluster/build_cache.rs: 661
- cluster.rs (submodule declarations): 279

## What this unlocks

Phase 5a is the substrate. The CLI wrapper (`claw-cargo build`) is
Phase 5b — thin glue that:
  1. Runs compute_workspace_fingerprint()
  2. Asks the peer BlobStat(fingerprint_as_blob_id)
  3. Hit → BlobGetStream + restore_target + cargo build (just the
     workspace's own crates, ~seconds)
  4. Miss → cargo build (full), then capture_target +
     push_blob_missing_chunks + PutManifest

Every piece of infrastructure that Phase 5b needs — content-addressed
blob store, streaming and partial-chunk RPC, mTLS transport, gossip-
driven peer discovery — is already merged. Phase 5b is CLI polish,
not new distributed-systems machinery.

## Follow-on

- Phase 5b: claw-cargo CLI wrapper (small — 200-400 lines)
- Phase 5c: pre-fetch on Gitea webhook (workflow triggers → daemon
  pre-warms the fingerprint on the target runner)
- Phase 3 remains a parallel track — human-readable namespace layer
  on top of raw fingerprints so operators can pin
  `clawverse:main:latest-cache` instead of a hex string
2026-07-11 23:27:57 -07:00
Omar Sobh eab10005fd Phase 2: content-addressed blob store
The storage substrate everything after Phase 1 sits on. Every blob is
identified by its BLAKE3 whole-content hash (BlobId); on disk it lives
as an ordered sequence of BLAKE3-hashed 4 MB chunks, so blobs that
share a prefix (two cargo target dirs with 95% of the same deps) share
storage at chunk granularity with no special detection logic.

New: cluster/blob.rs (802 lines).

Types:
- BlobId — 32-byte BLAKE3 output, hex-serialised (serde ↔ string)
- ChunkHash — same shape as BlobId but a distinct type so blob and
  chunk lookups can't accidentally swap
- BlobStat — { total_size, chunk_count }
- BlobManifest — { blob_id, total_size, chunks: Vec<ChunkHash> },
  public because Phase 2b RPC serves it directly so a receiver can
  request only the chunks it's missing
- GcReport — { chunks_scanned, chunks_removed, bytes_reclaimed }
- BlobStore — root-directory-based store

Public API:
- BlobStore::open(root)
- put_bytes(&[u8]) → BlobId
- get_bytes(&BlobId) → Option<Vec<u8>>  (verifies hash + size on read)
- contains(&BlobId) → bool
- stat(&BlobId) → Option<BlobStat>
- load_manifest(&BlobId) → Option<BlobManifest>
- delete_manifest(&BlobId) → bool   (chunks stay; orphan by GC)
- gc_orphan_chunks() → GcReport

On-disk layout:
  <root>/
    blobs/<bb>/<blob_hash>.manifest.json
    chunks/<cc>/<chunk_hash>
    .tmp/
Two-char bucket prefixes cap fan-out at 256 entries per level — safe
on a warm-tier ZFS dataset with tens of thousands of blobs.

Every write is atomic (tmp file + rename on same filesystem).
Every chunk write is a no-op if the file already exists — same
content across two put()s stores exactly one physical copy.

Correctness:
- Reads verify each chunk against its hash + recompute the whole-blob
  hash before returning; a bit-flipped chunk raises "chunk hash
  mismatch" instead of silently corrupting the answer.
- delete_manifest is the only deletion primitive; chunks are only
  ever removed by gc_orphan_chunks after a full manifest scan proves
  they're unreferenced.

Dep: blake3 = "1".

Tests (21 new, all real filesystem, no mocks):
- BlobId/ChunkHash hex round-trip + serde JSON
- BlobId::from_hex rejects wrong-length + non-hex input
- open creates blobs/, chunks/, .tmp/
- put + get round trip: small, empty, 10 MB (3 chunks)
- put is deterministic (same bytes → same BlobId every time)
- put is idempotent (writing twice → exactly one manifest file)
- Different content → different BlobId
- shared_chunks_are_stored_only_once: two blobs sharing a 4 MB prefix
  produce exactly 3 chunk files, not 4
- get_returns_none_when_missing / contains false / stat None
- delete_manifest keeps chunks (proven by counting chunk files)
- delete_manifest on missing returns false
- gc_reclaims_orphan_chunks_but_keeps_referenced: put 2 blobs, delete
  one manifest, GC removes exactly the orphaned chunk, keeps
  live A readable
- gc_on_empty_store_reports_zero
- corrupted_chunk_detected_on_read: rewrite a chunk with garbage →
  get_bytes errors with "chunk hash mismatch"
- load_manifest round-trips the chunk list

116 tests pass. Pre-existing macOS-only failure unchanged.

File size: cluster/blob.rs = 802 lines (ceiling 1300).

Follow-on Phase 2 cuts:
- 2b: RPC methods BlobStat / BlobGet / BlobPut, wired into RpcRouter
  and served over the QUIC transport built in Phase 1c-1e.
- 2c: streaming put/get (AsyncRead / AsyncWrite variants) for
  many-GB build artifacts.

Phase 3 (metadata + CRDTs) can start independently — this store is the
substrate the fingerprint cache in Phase 5 layers onto.
2026-07-11 22:32:59 -07:00
Omar Sobh 2ae079fbd4 Phase 1e: daemon-integrated cluster services + PeerStatus RPC
Ties Phase 1a-1d together into a live subsystem the daemon actually
runs. When `[cluster]` is present in config, `claw-store daemon` now:

  1. Starts chitchat gossip via ClusterServices::start.
  2. Publishes hot.max_gb immediately and re-measures the hot dir
     every 30s, updating `clawstor.hot.used`.
  3. If `[cluster.tls]` is also configured, loads NodeIdentity from
     PEM files and binds a QUIC RPC server that accepts + dispatches
     incoming Ping / PeerStatus requests via RpcRouter.

New modules:

- cluster/rpc.rs (510 lines):
  - Method enum (Ping=0x01, PeerStatus=0x02)
  - ErrorCode enum (EmptyRequest / UnknownMethod / HandlerFailure)
  - PeerStatusReply { local_name, local_zone, peers: Vec<PeerView> }
  - RpcRouter — dispatch state (holds Arc<ClusterGossip> + local
    name/zone)
  - rpc_call / call_ping / call_peer_status — client helpers
  - serve_connection — server accept-bidi loop
  - Wire format: `method:u8 || payload:bytes` → `reply:bytes` or
    single-byte ErrorCode

- cluster/services.rs (418 lines):
  - ClusterServices { gossip, accept_task, metric_task }
  - start(cluster_cfg, name, hot_dir, hot_max_bytes) → bootstraps
    everything above
  - shutdown() aborts background tasks cleanly
  - Recursive dir-walker (spawn_blocking) for hot-tier metric

PeerView now derives Serialize/Deserialize so it round-trips through
JSON over the RPC.

daemon.rs integration (~35 lines added):
  - Bootstraps ClusterServices before entering the select loop
  - Held for daemon lifetime
  - Shutdown on SIGTERM
  - Gossip-less config still runs standalone (backwards compat)

CLI: `cluster-peer-status --peer <name> --rpc-addr <addr>
                          --tls-dir <dir>`
  Loads a persistent identity, dials the peer, calls PeerStatus,
  prints the peer's local view as a table.

Tests (15 new, all real — no mocks, real UDP + TLS + JSON round trip):

RPC (9):
- method_round_trips_byte_encoding
- dispatch_returns_pong_for_ping
- dispatch_returns_json_for_peer_status
- dispatch_returns_empty_request_error
- dispatch_returns_unknown_method_error
- rpc_call_rejects_oversize_payload (with real quinn connection)
- end_to_end_ping_and_peer_status_over_real_quic — full 2-node quinn
  with mTLS + both RPCs
- peer_status_reflects_peer_gossip_state — 2 gossip instances converge,
  RPC caller from a third identity sees the converged view
- error_code_describe_covers_all_variants

Services (6):
- dir_bytes_sync_returns_zero_for_missing_path
- dir_bytes_sync_sums_recursive_file_sizes (3-level nesting)
- services_start_without_tls_leaves_rpc_disabled
- services_start_with_tls_serves_rpc_end_to_end — full stack: cut CA on
  disk, sign leaves, start ClusterServices for A, dial from B via
  persisted mTLS, run ping + PeerStatus over the wire
- services_publish_hot_used_metric_periodically
- services_gossip_sees_peer_after_convergence

95 tests pass. Pre-existing macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/rpc.rs: 510
- cluster/services.rs: 418
- cluster/gossip.rs: 579
- cluster/transport.rs: 916
- daemon.rs: 262
- main.rs: 749

Phase 1 done end-to-end. `claw-store daemon` now boots a real distributed
cluster stack when config asks for one; peers can call each other's
PeerStatus RPC and see live membership. Next: Phase 2 (content-addressed
blob store) can hook new RPC methods into the same RpcRouter with a
one-line dispatch arm.
2026-07-11 22:15:41 -07:00
Omar Sobh 4d93f955c5 Phase 1c: QUIC transport with fleet-CA mTLS + cluster-ping
Wraps quinn 0.11 in cluster/transport.rs with rustls 0.23 + rcgen 0.13
for identity management. Every peer connection is mTLS: both sides must
present certs signed by the shared fleet CA, and rustls verifies the
peer's cert SAN matches the requested server name.

Public API on `cluster::transport`:
- `NodeIdentity` (cert chain + private key + trusted CA)
- `NodeIdentity::generate_test_pair(a, b)` — ephemeral CA + two signed
  leaves; shape matches the production PEM-file loader (Phase 1d)
- `QuicServer::bind(addr, identity)` — bind mTLS-enforced listener
- `QuicServer::accept()` — accept one connection (Option<Result<_>>)
- `QuicClient::new(local_addr, identity)` — build client endpoint
- `QuicClient::connect(peer_addr, expected_name)` — outbound with SAN check
- `ping(&conn, payload)` — bidi stream RPC; server echoes as `pong:<payload>`
- `ping_handler_loop(conn)` — server-side accept/echo forever
- `CLAWSTOR_RPC_ALPN` constant, single ALPN "clawstor-rpc/1"

Config extension:
- `bind_rpc_lan`, `bind_rpc_tailscale` on `ClusterConfig` (both optional).
- Defaults: gossip port + 1 (so gossip UDP and QUIC UDP don't collide).
- Helper: `ClusterConfig::rpc_lan()`, `rpc_tailscale()`,
  `PeerEntry::rpc_lan()`, `rpc_tailscale()`.

Gossip.rs now advertises the RPC address, not the gossip address, on
the well-known `clawstor.rpc.lan` / `clawstor.rpc.tailscale` keys.

CLI:
- `cluster-ping --name <me> --peer <you> --rpc-addr <addr> [--payload X]`
  Runs a full mTLS handshake and single ping. For dev/loopback use
  today (both sides need to share a CA); persistent-identity ping
  lands in Phase 1d.

Tests (6 new, real UDP + TLS handshake, no mocks):
- generate_test_pair produces two distinct leaves that share a CA
- ping_pong_between_two_mtls_peers: real 2-node QUIC round trip with
  full mTLS chain verification, `open_bi()`/`accept_bi()`, byte-exact
  response check
- client_rejects_peer_with_wrong_ca: TLS chain verification failure
  when the server presents a cert signed by a different CA
- client_rejects_wrong_server_name: SAN mismatch is enforced
- server_binds_wildcard_and_reports_concrete_local_addr: port 0 → real
- ping_rejects_oversize_payload: MAX_MESSAGE_BYTES cap enforced client-side

Fixed-port allocator range 42000+ so transport tests don't conflict
with gossip tests (41000+).

72 tests pass. Pre-existing `hot::test_project_target_size_bytes`
macOS-only failure unchanged.

File sizes (all under 1300-line ceiling):
- cluster/transport.rs: 485
- cluster/gossip.rs: 570
- cluster.rs: 275
- config.rs: 480
- main.rs: 564

Follow-on Phase 1 cut (1d):
- Load NodeIdentity from persistent PEM files at /etc/claw-store/tls/
- Fleet CA bootstrap ceremony (rcgen → write CA cert; per-node leaf CSR)
- Daemon-level RPC server that runs alongside gossip + serves real
  operations (blob get/put, metadata sync)
- cluster-status reads from live daemon via API instead of standalone
2026-07-11 21:50:28 -07:00
Omar Sobh 5283ab8655 Phase 1b: chitchat SWIM gossip layer + cluster-status CLI
Wraps chitchat 0.11 in cluster/gossip.rs. Each node publishes its
zone, RPC endpoints, hot-tier usage, warm-tier project list, and
uptime under well-known kv keys; peers propagate this via gossip and
are classified alive/dead by phi-accrual failure detection.

Bootstraps from static [[cluster.peers]] seed nodes in the config.
Membership extends dynamically as nodes join or leave.

Public API on `ClusterGossip`:
- bootstrap(cluster_cfg, local_name) — start UDP gossip service
- set(key, value), set_hot_used, set_hot_max, set_warm_projects
- peers() — all known peers with typed PeerView + liveness
- peer(name) — one peer by advertised name
- peers_in_zone(zone) — live peers filtered by zone
- shutdown() — abort gossip task

CLI:
- `claw-store cluster-status --name <me> [--wait-secs N]`
  bootstraps gossip, waits for convergence, prints a peer table
  with zone, alive/dead, RPC LAN/Tailscale addresses, hot usage.

Tests (7 new):
- bootstrap_publishes_our_own_state — self-ID + advertised address
- bootstrap_fails_when_local_name_empty
- bootstrap_fails_when_no_bind_address — cluster.validate error chain
- two_node_cluster_converges_and_shares_state — REAL two-node
  in-process UDP gossip. Publish state on A, wait for phi-accrual
  liveness on B, verify all fields propagated (zone, rpc_lan, hot
  bytes, warm_projects list, fill ratio, zone filtering).
- peers_excludes_self — solo cluster reports zero peers
- peer_view_hot_fill_ratio_handles_missing_or_zero

No mocks. Real UDP transport, real chitchat runtime, real gossip
protocol. Ports allocated from a fixed range (41001+) via atomic
counter — deterministic and race-free within the test binary.

66 tests pass. Pre-existing `hot::test_project_target_size_bytes`
macOS-only failure unchanged.

File sizes (under 1300-line ceiling):
- cluster/gossip.rs: 549
- cluster.rs: 274
- main.rs: 506

Follow-on Phase 1 cuts:
- 1c: `quinn` QUIC transport + fleet-CA mTLS for RPC (separate UDP
      port so gossip and QUIC don't collide)
- 1d: daemon wire-up — gossip runs in background, hot-tier usage
      pushed periodically, `cluster-status` reads from live daemon
2026-07-11 21:41:52 -07:00
Omar Sobh 5c19c60292 Phase 1a: cluster module + LAN-first peer probe
First cut of the v2 distributed FS. Captures the architecture design
in ARCHITECTURE-v2.md and lands the smallest useful new capability:
probing cluster peers with a LAN-first policy so subsequent transport
+ gossip layers (Phase 1b, 1c) can build on a real routing decision.

New:
- ARCHITECTURE-v2.md: zones (fabric-10g/lan-1g/roaming), tier
  lifecycle (hot/warm/cold), fingerprint-keyed build cache design,
  smart-clean policy, phase plan, explicit non-goals.
- claw-store/src/cluster.rs: RouteKind, RouteWinner, LanFirstProbe.
  LAN 200ms timeout, Tailscale 500ms fallback. 8 tests use real
  TCP listeners on 127.0.0.1 (no mocks); cover happy path,
  fall-through, both-fail, single-address, and elapsed reporting.
- claw-store/src/config.rs: ClusterConfig + PeerEntry with
  validation (bind-address presence, no duplicate peer names,
  per-peer reachable address required). Optional at top level so
  pre-v2 configs still load unchanged. 6 new tests.
- claw-store/src/main.rs: `claw-store cluster-probe <peer>` CLI
  subcommand that reads config, resolves the peer, probes, prints
  the winning route + elapsed time.

All 16 new tests pass. Existing 45 pass. Sole failure
(hot::tests::test_project_target_size_bytes) is a pre-existing
macOS-only issue with `du -sb`; Linux CI unaffected.

Follow-on Phase 1 cuts (subsequent sessions):
- 1b: chitchat SWIM gossip for live membership state
- 1c: quinn QUIC transport with fleet-CA mTLS
- 1d: `claw-store cluster status` — live membership view

Every file well under the 1300-line ceiling
(cluster.rs 268, config.rs 428, main.rs 450).
2026-07-11 21:30:50 -07:00