Commit Graph
103 Commits
Author SHA1 Message Date
Omar Sobh 8ada892e37 workflow: constrain runner to linux-amd64 to avoid macOS matcher
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 4s
Bare self-hosted matched a macOS runner (smith). Compound label
narrows to tank/architect where claw-cargo is provisioned.
2026-07-13 01:39:51 -07:00
osobh d8cfe954ac Merge pull request 'workflow: force host mode via self-hosted label' (#33) from force-host-runner into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-13 08:38:20 +00:00
Omar Sobh 4d309137e7 workflow: force host mode via self-hosted label
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 5s
ubuntu-latest routes to container mode in act_runner even with the
:host suffix on the runner labels. Explicit self-hosted forces
host-mode where /usr/local/bin/claw-cargo + per-runner tls_dir are
visible.
2026-07-13 01:37:45 -07:00
osobh a2bf355ce1 Merge pull request 'workflow: drop apt/rustup install steps for host runner' (#32) from fix-workflow-no-sudo into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-13 08:35:41 +00:00
Omar Sobh cb9d3d5180 workflow: drop apt/rustup install steps for host runner
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Host runners have cmake/gcc/pkg-config from the OS and cargo/rustup
in the act_runner user's ~/.cargo/bin. apt-get needs root — the
runner isn't. Replace with a preflight that fails fast when any
tool is missing.
2026-07-13 01:35:23 -07:00
osobh 7b33390da6 Merge pull request 'gitea: composite cargo-cache action + runner-integration doc' (#31) from gitea-action-cargo-cache into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 4s
2026-07-13 08:30:30 +00:00
Omar Sobh 89f5892e20 gitea: composite cargo-cache action + runner-integration doc
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 4s
Ships the wire-up piece for real CI: a composite Gitea Action that
wraps `claw-cargo build` with cache-outcome reporting, plus a
matching workflow file that opts the clawstor repo itself into
being cache-hit-tested on every push. Also docs the one-time
per-runner provisioning (leaf cert, PATH install, config.toml).

* `.gitea/actions/cargo-cache/action.yml` — composite Action.
  Inputs: workspace, profile, no-upload, parallel-restore. Outputs:
  cache-outcome (HIT|MISS|POPULATED|SKIPPED), fingerprint,
  elapsed-seconds. Runner-side config lives in
  `~/.config/claw-cargo/config.toml` (not in the workflow — no
  secrets shipped from repos).
* `.gitea/workflows/build-with-cache.yml` — dogfoods the action on
  clawstor's own repo. `no-upload` set from event_name so PRs from
  forks can't poison the cache.
* `docs/runner-integration.md` — one-time setup steps, sample
  workflow snippet, expected numbers (Pi 5: 2.79× wall, tank:
  2.18×), and troubleshooting for the failures I hit in the tank
  and Pi pilots (bind_lan on fabric-only, missing CLI/config,
  rustc drift warn).

Test protocol: push this branch → main triggers the workflow → the
runner on tank has claw-cargo + tls + config provisioned already
(2026-07-13 pilot setup) → first build should MISS + populate,
subsequent build on same fingerprint should HIT.
2026-07-13 01:29:51 -07:00
osobh d34b171248 Merge pull request 'claw-cargo: default --parallel-restore back to 1 (sequential wins on loopback)' (#30) from default-parallel-restore-1 into main 2026-07-13 08:09:54 +00:00
Omar Sobh 1053930451 claw-cargo: default --parallel-restore back to 1 (sequential)
Pi 5 loopback measurement 2026-07-13:

  --parallel-restore 1 : wall 2m52s, restore 20s
  --parallel-restore 8 : wall 3m06s, restore 34s

Sequential is 70% faster on loopback. N-way stream contention costs
more than a single stream's congestion-control amortization. Same
shape as Phase 5k prewarm — fanout only wins when per-stream
throughput has a ceiling (WAN, tunneled links).

--parallel-restore N remains as opt-in.
2026-07-13 01:09:39 -07:00
osobh 5261260328 Merge pull request 'systemd: whitelist XDG state path in shipped unit' (#29) from systemd-xdg-readwritepaths into main 2026-07-12 14:45:22 +00:00
Omar Sobh 9fe6bf4772 systemd: whitelist XDG state path so first-run projects.toml write succeeds
Follow-up to PR #28. Fresh Pi deploy 2026-07-12 hit a `ProtectHome=
read-only` block on the daemon's XDG-driven
$HOME/.local/state/claw-store/projects.toml write. Adding the path
to `ReadWritePaths` in the shipped unit means future deployers
don't need a drop-in.
2026-07-12 07:45:18 -07:00
osobh a5880ff12d Merge pull request 'Pi deploy follow-ups: XDG default_path + parallel restore' (#28) from pi-followups into main 2026-07-12 14:43:11 +00:00
Omar Sobh 846ecffe10 Pi deploy follow-ups: XDG default_path + parallel restore
Two fixes surfaced by the vision-02 Pi 5 measurement:

## XDG default_path

`Manifest::default_path` was hardcoded to
`/var/lib/claw-store/projects.toml`. That path is read-only under
the user-mode systemd unit's `ProtectSystem=strict`, and creating
it needs root — awful for a runner install.

Precedence, matching XDG Base Directory:
  1. `$XDG_STATE_HOME/claw-store/projects.toml`
  2. `$HOME/.local/state/claw-store/projects.toml`
  3. `/var/lib/claw-store/projects.toml` (system fallback)

User-mode installs now write in $HOME by default; system installs
(root, no HOME set) still land in /var/lib.

+1 test: `default_path_honours_xdg_state_home` — covers all three
precedence branches. Env mutation is process-global so the test
saves + restores.

## Parallel restore on cache HIT

Pi restore of 947 MiB via `BlobGetStream` took ~18s (~53 MiB/s)
single-stream. Per-stream throughput ceilings on the connection
type cap sequential fetches; parallel chunk fetches stack their
contributions.

- New `call_blob_get_parallel(conn, blob_id, concurrency) ->
  Option<Vec<u8>>` in `rpc/client.rs`. `JoinSet` + `Semaphore`,
  reassembles by chunk index at manifest-known offsets so
  out-of-order arrival is fine.
- `claw-cargo build --parallel-restore N` (default 8). `N <= 1`
  falls through to `BlobGetStream` for parity.
- Memory: `total_size + 4 MiB × in-flight` — dominated by the
  reassembly buffer, not the fanout.

+1 test: `parallel_blob_get_reassembles_multi_chunk_blob_byte_equal`
covers roundtrip byte-equality vs BlobGetStream, tail-chunk offset,
concurrency=1 correctness, and NotFound → None.

259 tests pass (+2). Pre-existing macOS failure unchanged.
2026-07-12 07:42:57 -07:00
osobh e7d4c824b6 Merge pull request 'blob: size-based LRU eviction + auto-cap in the GC ticker' (#27) from lru-eviction into main 2026-07-12 13:35:33 +00:00
Omar Sobh 2f3055a3aa blob: size-based LRU eviction + auto-cap in the GC ticker
Orphan-chunk GC alone doesn't stop unbounded growth: as long as
fingerprint→blob refs keep getting PutRef'd, the manifest set keeps
growing and no chunk is ever an orphan.

* `BlobStore::evict_to_size_cap(max_bytes)` — walks manifests oldest
  first by mtime, deletes them, refcount-decrements each chunk they
  used, unlinks + reclaims size for any chunk whose refcount hits
  zero. Shared chunks stay put until the last blob referencing them
  is evicted.
* `ManifestSummary` internal type keeps the diff-set bookkeeping
  cheap (one HashMap<ChunkHash, u32>, no repeated tree walks).
* `claw-store cluster-gc --evict-to-gb <N>` extends the CLI: still
  runs the orphan sweep first, then optionally caps the store.
* Config: `cluster.blob_max_gb: Option<u64>`. The auto-GC ticker
  runs eviction after every orphan sweep when this is set. Silent
  when the store is already under cap; INFO log when it evicts.

+3 tests:
- evict_to_size_cap_reclaims_oldest_blobs_first: 3 blobs with
  distinct mtimes, cap below combined size → oldest evicted,
  newer blobs survive
- evict_keeps_shared_chunks_when_still_referenced: guards the
  refcount decrement path (content-addressed dedup keeps identical
  content as one blob → chunk survives until manifest deleted)
- evict_on_empty_store_is_a_noop: sanity

257 tests pass (baseline +3). Pre-existing macOS failure unchanged.
2026-07-12 06:35:17 -07:00
osobh feb0efe36c Merge pull request 'Two pilot follow-ons: rustc drift warning + blob GC' (#26) from rustc-drift-warning-and-gc into main 2026-07-12 13:24:01 +00:00
Omar Sobh 84aa758fd4 Two pilot follow-ons: rustc drift warning + blob GC
Both surfaced by the 2026-07-12 pilot as real operator concerns:

## rustc drift warning at build time

Runners silently silo their cache when rustc versions differ across
peers (fingerprint depends on rustc verbose output). The pilot's
first flow burned a full cold+upload before we realized the silo.

- `PeerStatusReply.local_rustc_release` — new field, populated from
  the peer's own gossip `RUSTC_RELEASE` key via a new
  `ClusterGossip::self_kv(key)` accessor.
- `claw-cargo build`: on cache MISS, calls `PeerStatus`; if the
  peer's rustc release ≠ our local `rustc --version`, emits a WARN
  with both versions + hint to add `rust-toolchain.toml`.
- Best-effort: absence of either release string is a shrug, not
  an error.

## blob GC

Blob store grows unbounded on a runner; disk-full is a real
incident. `gc_orphan_chunks` already existed but wasn't exposed.

- New CLI: `claw-store cluster-gc` — runs `gc_orphan_chunks`,
  prints report. Safe to run any time, safe to interrupt.
- New config: `cluster.gc_interval_hours: Option<u64>`. When set to
  a positive integer, the daemon spawns a periodic ticker that
  invokes GC in-process. Skips the first tick (nothing to reclaim
  on boot). Errors are logged and retried next tick.
- Shutdown aborts the ticker cleanly.

254 tests pass (baseline unchanged). Pre-existing macOS failure
untouched.
2026-07-12 06:23:46 -07:00
osobh b0c11f4603 Merge pull request 'Phase 5k: parallel-fanout chunk transfer for prewarm' (#25) from phase-5k-parallel-prewarm into main 2026-07-12 13:13:45 +00:00
Omar Sobh 54e9da4d62 Phase 5k: parallel-fanout chunk transfer for prewarm
Pilot 2026-07-12 measured 109 MiB/s on the sequential prewarm path —
~11% of a 10G fabric. `quinn::Connection` is cheap-Clone (internal
Arc), so we can run the has→get→put pipeline per chunk in concurrent
tasks under a bounded semaphore.

- `prewarm_missing_chunks_between_parallel(up, down, id, concurrency)`
  in `rpc/client.rs`. `concurrency <= 1` degrades to the sequential
  path (kept for diagnostic parity).
- `claw-cargo prewarm --parallel N` (default 8). Ignored with
  `--buffered`. Memory ceiling: 4 MiB × in-flight = 32 MiB @ 8,
  128 MiB @ 32.
- Uses `tokio::task::JoinSet` + `Arc<Semaphore>`; permit held for
  the whole per-chunk pipeline so we never over-commit.
- Retry pass on `put_manifest` mismatch stays sequential — small,
  correctness-critical.
- Errors: JoinSet drains completely + returns first task error so a
  mid-fanout failure doesn't leave zombie tasks.

+1 test: `end_to_end_parallel_prewarm_copies_chunks_and_matches_sequential`
runs 5-chunk payload with concurrency=3, verifies byte-equal restore,
then reruns with concurrency=8 → 0 uploads (has_chunk dedup), then
concurrency=0 → 0 uploads (sequential fallback path).

254 tests pass (+1 from previous). Pre-existing macOS failure unchanged.
2026-07-12 06:13:28 -07:00
osobh a8fac47470 Merge pull request 'systemd: cluster daemon unit for production lifecycle' (#24) from systemd-cluster-unit into main
Reviewed-on: #24
2026-07-12 13:09:30 +00:00
osobh 526b15b6fc Merge pull request 'capture: stream to a Writer instead of buffering the whole tar in RAM' (#23) from streaming-capture into main
Reviewed-on: #23
2026-07-12 13:09:20 +00:00
osobh 5469b916fd Merge pull request 'transport: bump QUIC idle timeout + keep-alive for long cargo runs' (#22) from fix-quic-idle-during-cargo-build into main
Reviewed-on: #22
2026-07-12 13:09:08 +00:00
Omar Sobh 264ee81189 systemd: cluster daemon unit for production lifecycle
Pilot ran the cluster daemon under nohup; production needs proper
restart-on-failure + clean PATH inheritance (rustc gossip probe
needs `~/.cargo/bin` on PATH, which nohup's env didn't get).

The unit is user-scoped (`~/.config/systemd/user/`) so it works
without root on the pilot nodes:

  cp systemd/clawstor-cluster.service ~/.config/systemd/user/
  loginctl enable-linger $USER
  systemctl --user daemon-reload
  systemctl --user enable --now clawstor-cluster.service

Defaults:
- CLAWSTOR_BIN = ~/clawstor-deploy/claw-store
- CLAWSTOR_CONFIG = ~/clawstor-deploy/config.toml
- PATH prefixed with ~/.cargo/bin so rustc is found

Override any of those via `systemctl --user edit
clawstor-cluster.service`.

Security hardening:
- NoNewPrivileges=yes
- ProtectSystem=strict (system dirs read-only)
- ProtectHome=read-only (home dir read-only)
- ReadWritePaths=%h/clawstor-deploy (only the deploy tree is
  writable)
- PrivateTmp=yes

Restart semantics:
- Restart=on-failure with RestartSec=10 — pilot-verified: kill -9
  the daemon PID and the service comes back within ~10s
- TimeoutStopSec=60 so a slow gossip departure can complete

Deployed to tank + architect 2026-07-12 as part of the pilot
retest.
2026-07-12 06:07:30 -07:00
Omar Sobh cb07bfc574 capture: stream to a Writer instead of buffering the whole tar in RAM
Field finding 2026-07-12 (clawverse measurement): the buffered
`capture_target -> Vec<u8>` path peaked at 2.8 GB RAM to capture a
6.1 GB target/debug into a 995 MiB compressed tar. Every byte
crossed RAM before touching the network.

* `capture_target_to_writer(target_dir, writer) -> u64` — new
  streaming variant. Walks the tree + writes tar+zstd straight into
  the caller's Writer via a small ByteCounter wrapper. Peak memory
  stays at ~zstd sliding window size (few MB).
* `capture_target -> Vec<u8>` kept as a thin wrapper for the tests
  + smaller callers that don't care.
* `cmd_build`: capture into a tempfile under `target/`, then open
  it with `tokio::fs::File` (AsyncRead + Unpin) and hand that to
  `call_blob_put_stream`. Same-filesystem tempfile means no cross-
  mount concerns; auto-unlinks on drop.

+1 test: `capture_streaming_matches_buffered_and_restores_correctly`
proves the streamed bytes match the buffered variant, the reported
byte count agrees with the written length, and roundtrip restore
from the streamed file works.

Combined with PR #22 (QUIC idle timeout), this closes the two RAM/
timeout blockers surfaced by the clawverse pilot. Expected memory
ceiling on a runner drops from GBs to MBs, unlocking small-runner
deployments (the actual pitch use case).
2026-07-12 06:02:55 -07:00
Omar Sobh c08f60a2aa transport: bump QUIC idle timeout + add keep-alive for long builds
Field finding 2026-07-12 (clawverse cold on tank):

    Compiling claw-cli v0.1.0 (...)
    Finished `dev` profile ... in 45.08s
    cargo build finished in 45.135491979s
    Error: opening bidi stream for BlobPutStream
    Caused by: timed out

Cargo took 45s → QUIC's 30s idle timeout killed the connection between
the initial peer-lookup connect and the follow-up capture+upload path.
The RPC never got a chance to open its stream.

Fix: two belt-and-braces changes:
1. IDLE_TIMEOUT 30s → 600s. The timeout is there to detect crashed
   peers, not to enforce build pacing.
2. Client applies a `keep_alive_interval` of 15s so the connection
   stays warm across cargo runs even shorter than the idle window.

quinn's keep-alive fires from an internal runtime task, not the app
thread, so a fully-CPU-pinned cargo build doesn't suppress it.
2026-07-12 05:53:21 -07:00
osobh c79629d4dc Merge pull request 'Pilot findings: 5 real-world fixes from 2026-07-12 deploy' (#21) from pilot-fixes into main
Reviewed-on: #21
2026-07-12 12:42:49 +00:00
Omar Sobh e70f5d74e0 Pilot findings: 5 real-world fixes from the 2026-07-12 deploy
Bundles the profile→dir bug (PR #20 supersede) with four new fixes
discovered by running clawstor against itself + across the fabric:

* target_subdir_for: `dev`/`test` → `debug/`, `release`/`bench` →
  `release/`, custom passes through. Was silently skipping upload.

* rustc release via gossip: daemon probes `rustc --version` at start,
  publishes the release string as `clawstor.rustc.release`. PeerView
  carries it; `cluster-peer-status` prints it in a new column and
  emits a warning line when the fleet has mixed versions. Would have
  surfaced the tank/architect 1.96.1 vs 1.95.0 drift instantly.

* prewarm publishes fingerprint→blob ref downstream: `pin` now writes
  a companion tag `<name>.fingerprint` holding the fingerprint bytes.
  `prewarm` reads the companion, PutTag's it downstream, then
  PutRef(fp→blob) so a subsequent fingerprint-based `build` HITS.
  Without this, prewarm was almost useless for the runner path
  (build always missed even with matching source + rustc).

* streaming byte counters: BlobPutStream + BlobGetStream now record
  the transferred bytes via `record_blob_{put,get}_bytes`. Metric
  used to stay at 0 no matter how much you moved.

* capture determinism: replaced `tar::Builder::append_dir_all` (uses
  `read_dir`'s native order) with `append_dir_sorted` that walks the
  tree recursively and sorts by filename bytes at every level. Two
  byte-identical trees now produce byte-identical tars regardless of
  filesystem ordering.

+3 tests:
- target_subdir_matches_cargo_layout (from #20)
- fingerprint_companion_tag_uses_dotted_suffix
- capture_is_order_independent_of_filesystem_readdir (guard against
  the exact bug we saw in the field)

252 tests pass (+1 from Phase 5h's 251). Pre-existing macOS `du -sb`
failure unchanged.

Supersedes #20 (also included here). Ready for re-deploy to
tank + architect for the retest run.
2026-07-12 05:34:36 -07:00
osobh cdde769987 Merge pull request 'Phase 5h: streaming chunk-level prewarm' (#19) from phase-5h-streaming-prewarm-v2 into main
Reviewed-on: #19
2026-07-12 12:01:02 +00:00
Omar Sobh 6fb16286dd Phase 5h: streaming chunk-level prewarm
Bounded-memory cross-peer prewarm: instead of buffering the whole
blob in RAM (previous 5f path), iterate the upstream manifest chunk
by chunk, ask downstream `HasChunk`, stream missing chunks one at a
time. Memory ceiling is 1 chunk (4 MiB) regardless of blob size — a
5 GiB target dir no longer needs 5 GiB of mediator RAM.

* rpc/client.rs: new `prewarm_missing_chunks_between(upstream,
  downstream, blob_id)` helper. Returns `(uploaded, total)` — the
  difference is the dedup save. Retries once if the downstream
  `PutManifest` reports missing chunks after our push (guards a
  narrow eviction race); a second failure surfaces as `Err`.
* claw_cargo.rs: `prewarm` now streams by default; new `--buffered`
  flag for the old whole-blob path (kept for diagnostic
  comparability during rollout). Human-readable output shows the
  mode + dedup count.

+2 tests:
- cold downstream: 3-chunk payload (with a partial tail chunk) is
  copied exactly, reassembly is byte-equal to source
- partial dedup: pre-seed 1 of 3 chunks on downstream → uploaded=2;
  rerun is a full no-op (uploaded=0), proving idempotence

251 tests pass (+2 from Phase 5j). Pre-existing macOS failure
unchanged.

Follow-ons: (1) parallel chunk transfer (uploaded chunks in fan-out)
would speed multi-GB prewarms further; (2) exposing an accurate
transferred-bytes counter needs router-side accounting instead of
the current chunk-count × CHUNK_SIZE approximation.
2026-07-12 04:48:55 -07:00
osobh 1fe53ac2e6 Merge pull request 'Phase 5j: Prometheus /metrics endpoint' (#18) from phase-5j-prometheus-endpoint into main
Reviewed-on: #18
2026-07-12 11:42:43 +00:00
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
osobh 41e911f3d7 Merge pull request 'Phase 5i: publish cache metrics via gossip' (#17) from phase-5i-gossiped-cache-metrics into main
Reviewed-on: #17
2026-07-12 11:28:39 +00:00
Omar Sobh 29be728089 Phase 5i: publish cache metrics via gossip
ClusterServices now periodically snapshots the router's CacheMetrics
and republishes four raw counters — GetRef hits/misses and blob
GET/PUT byte totals — through chitchat as clawstor.cache.* keys.
Peers derive the hit rate locally via PeerView::cache_get_ref_hit_rate,
eliminating a per-peer GetMetrics roundtrip for placement decisions.

* gossip.rs: 4 new well-known keys, PeerView carries the counters +
  a saturating-add derived hit rate that returns None on 0/0 or when
  either counter is missing (guards against half-writes reading as
  100% hits).
* services.rs: router hoisted out of the TLS branch so a
  cache_metric_task can hold Arc<RpcRouter>. Publishes once at start
  (initial zeros so peers don't wait 60s for first read) then every
  CACHE_METRIC_INTERVAL. Ticker skipped when RPC isn't up — counters
  only fire inside dispatch.

+3 tests:
- gossip: two-node convergence with cache counters + hit-rate math
- gossip: half-write / 0-0 / normal PeerView cases return correct rates
- services: fresh cluster sees Some(0) for all four keys, then after
  in-process router counters + explicit set_cache_metrics the peer sees
  the updated values with hit rate 0.8

241 tests pass (+3 from Phase 5g). Pre-existing macOS `du -sb` failure
in hot::tests unchanged.
2026-07-12 04:20:45 -07:00
osobh cfe6f6ad5b Merge pull request 'Phase 5g: cache metrics + GetMetrics RPC + peer-metrics CLI' (#16) from phase-5g-cache-metrics into main
Reviewed-on: #16
2026-07-12 11:16:04 +00:00
osobh cd717c6ecc Merge pull request 'Phase 5f: claw-cargo prewarm — cross-peer cache copy' (#15) from phase-5f-prewarm into main
Reviewed-on: #15
2026-07-12 11:15:48 +00:00
osobh 937b8a5ff6 Merge pull request 'Phase 5e: prefetch --pin <tag>' (#14) from phase-5e-prefetch-pin into main
Reviewed-on: #14
2026-07-12 11:15:35 +00:00
osobh 14d3a1efb6 Merge pull request 'Phase 5d: named tags + pin/unpin/list-tags CLI' (#13) from phase-5d-named-tags into main
Reviewed-on: #13
2026-07-12 11:15:18 +00:00
osobh 3ee6aa6075 Merge pull request 'Phase 5c: claw-cargo UX (config files + status + prefetch)' (#12) from phase-5c-claw-cargo-ux into main
Reviewed-on: #12
2026-07-12 11:14:59 +00: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 db55903311 Phase 5f: claw-cargo prewarm — cross-peer cache copy
The last piece before "Gitea webhook triggers a cache-warm for the
CI runner before its build starts." Adds a `prewarm` subcommand that
copies a tagged cache from one peer (upstream) to another (downstream)
in one shot — same tag, same BlobId, both sides serve it after.

## New subcommand

```
claw-cargo prewarm \
  --from-peer tank --from-addr 10.0.0.14:7702 \
  --to-peer   morpheus --to-addr 10.0.0.15:7702 \
  --tls-dir   /etc/claw-store/tls \
  --pin       clawverse:main:latest
```

Flow:
1. Connect to upstream with local mTLS identity
2. `GetTag(tag)` → BlobId; `BlobStat(BlobId)` → size + chunk count
3. `BlobGetStream(BlobId)` → download bytes
4. Connect to downstream (second QUIC endpoint, same identity)
5. `BlobPutStream(bytes)` → returns BlobId; verified equal to upstream's
6. `PutTag(tag → BlobId)` on downstream

Summary output shows tag, blob id, both endpoints, byte count,
download/upload timings, total wall clock.

Assumes upstream + downstream share the same fleet CA (the common
case). Mixed-fleet variant with distinct identities is a follow-on.

## Integrity check

`assigned_id != blob_id` after the downstream upload triggers a
bail — the two BlobIds must match because content is BLAKE3-hashed
end-to-end. If they don't, the wire path corrupted bytes and the
whole prewarm fails loud rather than silently pinning a bad blob.

## Buffered vs streamed

Current implementation buffers the whole blob in memory between
download and upload. Fine for cargo target dirs (~1-5 GB compressed);
would break for a 20 GB blob. A follow-on will pipe upstream → tokio
duplex → downstream to run at fixed memory.

## Tests (1 new, real 2-peer QUIC)

- **`end_to_end_prewarm_copies_tagged_blob_between_two_peers`**
  Two full RpcRouters serving in-process (A upstream + C downstream),
  each on a distinct port. Seeds A with a blob + tag, then runs the
  exact sequence prewarm runs internally: `GetTag → BlobGetStream`
  against A, then `BlobPutStream → PutTag` against C. Verifies that
  C's blob store returns byte-equal payload and C's tag store now
  points at the same BlobId. Proves the composition works.

## Housekeeping

`rpc/tests.rs` hit 1408 lines with the new prewarm test. Phase 5
tests (5b refs + 5d tags + 5e restore + 5f prewarm) split to
`rpc/tests_phase5.rs` via a second `#[path]` module in rpc.rs.
Result:

- rpc/tests.rs: 727 (phase 1-2d tests)
- rpc/tests_phase5.rs: 722 (phase 5 tests)
- rpc.rs: 777
- rpc/client.rs: 589
- claw_cargo.rs: 818
- All under ceiling.

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

## What this enables

The complete CI runner flow now works end-to-end:

```
Primary (e.g. tank):
  claw-cargo build          # first ever build — MISS, uploads
  claw-cargo pin --name clawverse:main:latest

Fleet control plane on PR open:
  gitea webhook → shell hook → claw-cargo prewarm \
    --from-peer tank --to-peer $RUNNER_LOCAL \
    --pin clawverse:main:latest
  # runner's local daemon now serves the tag + blob

Runner picks up job:
  claw-cargo build --peer 127.0.0.1:7702
  # local daemon is warm → prefetch returns HIT
  # cargo build runs against restored deps → workspace-crates only
  # 50 min → 3 min
```

Every subcommand claw-cargo needs for this pipeline now exists:
build / prefetch / prefetch --pin / status / fingerprint /
pin / unpin / list-tags / prewarm.

## What's next

- 5g: cache hit/miss metrics into gossip so placement engines can
  bias runner scheduling toward warm nodes
- 5h: streaming variant of prewarm (tokio duplex) for many-GB blobs
- 6: FUSE mount for warm-tier git worktrees
- 3: full CRDT metadata if the plain-tag model shows conflict problems
2026-07-12 03:57:10 -07:00
Omar Sobh 05ba800d01 Phase 5e: prefetch --pin <tag>
Small, focused extension to Phase 5c's prefetch: an optional
`--pin <tag-name>` flag that skips fingerprint compute entirely
and resolves the tag → BlobId via GetTag, then downloads that.

## Use case

Restore an old cache into a fresh checkout for regression testing:

  $ claw-cargo prefetch --pin clawverse:main:2026-07-12
  cache HIT — downloading 3221225472 bytes (768 chunks) to /path/target/dev
  ...
  ── claw-cargo prefetch ─────────────────────────────
  source:      --pin clawverse:main:2026-07-12
  blob:        8c2f1a…
  downloaded:  3221225472 bytes in 12.3s
  restored to: /path/target/dev
  ────────────────────────────────────────────────────

Or diagnose a "why does this build fail against the pinned cache"
question by prefetching the tagged cache and then running cargo
against your current source. Cargo will detect the mismatched
.fingerprint state and rebuild affected crates — that's the point,
you're diffing behaviour between two known-good cache snapshots.

## Changes

- New PrefetchArgs struct (was reusing PeerArgs) with an optional
  `pin: Option<String>` field
- resolve_pin(conn, tag) — internal helper that does
  GetTag → BlobStat, returning None on either NotFound
- cmd_prefetch branches at the top: --pin → resolve_pin(); default
  → fingerprint-based peer_lookup()
- Rest of the flow is unchanged: BlobStat → BlobGetStream →
  restore_target
- Summary output shows `source: --pin <tag>` instead of
  `fingerprint: <hex>` when the pinned path was taken

`peer_lookup` (fingerprint path) and `resolve_pin` (tag path) return
the same `Option<(BlobId, BlobStat)>` shape so the downstream code
is identical.

## Live smoke test

`prefetch --help` now advertises --pin with full description.
Missing-tag path prints "no such tag: <name>" and exits 0
(consistent with the fingerprint-miss path).

## Tests (1 new, real QUIC)

- **`end_to_end_tag_resolve_and_stream_restore_over_real_quic`** —
  seeds blob store with a 2 MiB "captured target" payload, publishes
  a tag pointing at its BlobId, then runs the exact client
  sequence `prefetch --pin <tag>` runs internally:
    GetTag → BlobStat → BlobGetStream
  Verifies bytes reassemble byte-equal to source. Also covers the
  missing-tag path.

The pin flow uses the same underlying calls tested separately in
Phase 5b/5c/5d, so the new test proves the composition works rather
than re-verifying primitives.

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

## What's next

- 5f: Gitea webhook pre-fetch — daemon receives PR-open hints and
  warms cache for the predicted fingerprint before CI runner starts
- 6: FUSE mount for warm-tier git worktrees so `~/projects/clawverse`
  is transparently fleet-shared
- 3: full CRDT metadata layer (only if real conflicts emerge in the
  simple tag model)
2026-07-12 03:49:42 -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
osobh fdc943b2ff Merge pull request 'Phase 5b: KV refs + claw-cargo CLI (killer feature, live)' (#11) from phase-5b-claw-cargo into main
Reviewed-on: #11
2026-07-12 06:39:25 +00:00
osobh ced6fc6d78 Merge pull request 'Phase 5a: fingerprint + capture + restore for build-artifact cache' (#10) from phase-5a-build-cache into main
Reviewed-on: #10
2026-07-12 06:39:13 +00:00
osobh 0455c561ee Merge pull request 'Phase 2d: chunk-level RPC (HasChunk/PutChunk/GetChunk/PutManifest)' (#9) from phase-2d-chunk-rpc into main
Reviewed-on: #9
2026-07-12 06:38:56 +00:00
osobh 831bbb0de0 Merge pull request 'Phase 2c: streaming Blob RPC' (#8) from phase-2c-blob-streaming into main
Reviewed-on: #8
2026-07-12 06:38:46 +00:00
osobh 01f21afc00 Merge pull request 'Phase 2b: Blob RPC (Stat/Get/Put/LoadManifest)' (#7) from phase-2b-blob-rpc into main
Reviewed-on: #7
2026-07-12 06:38:34 +00:00
osobh 4342c2053f Merge pull request 'Phase 2: content-addressed blob store' (#6) from phase-2-blob-store into main
Reviewed-on: #6
2026-07-12 06:38:22 +00: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