Commit Graph
123 Commits
Author SHA1 Message Date
osobhandClaude Sonnet 5 e5fcb8b3f4 Fix stdout/stderr ordering in ShutdownPrepCheck
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Command::output() captures stdout and stderr as two separate
buffers. check() was concatenating stdout-then-stderr, which throws
away chronological order entirely -- every stderr line (e.g.
"Error: send_to_host not set" from `claw-store replicate` on a node
with no downstream replication target, which is normal and expected
on architect) landed at the very end of the report regardless of
when it actually printed, making a mid-script, already-handled
condition look like a failure that happened after "DRY RUN
COMPLETE".

Fix: invoke via `bash -c "script --dry-run 2>&1"` so stderr merges
into stdout inside the shell, before either stream reaches us --
true chronological order preserved, single buffer to read.

Verified on architect: the send_to_host message now appears exactly
where it happens, inside the "taking final snapshot + replicating"
step, with DRY RUN COMPLETE correctly last.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 19:45:29 -07:00
osobhandClaude Sonnet 5 f38efc7096 Honor zfs_dataset = "none" instead of erroring; surface shutdown-prep panel from fleet view
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 4s
Two problems surfaced while checking on the fleet after the shutdown-
prep button PR:

1. morpheus is configured with zfs_dataset = "none" (it has no ZFS
   pool -- warm tier is a plain directory on the LVM root volume),
   but nothing in the code actually implemented that as a sentinel.
   cmd_snapshot/cmd_replicate and the daemon's periodic snap/repl
   ticks always tried real zfs/zpool calls regardless, producing
   "zfs: command not found" errors on every hourly tick and in the
   shutdown-prep report. WarmConfig::zfs_enabled() now gates all four
   call sites; a non-ZFS node gets a clean "nothing to
   snapshot/replicate" instead of a raw shell error.

2. safe-shutdown-prep.sh's zpool-health step now checks `command -v
   zpool` first instead of leaking "zpool: command not found" into
   the report.

3. "we don't see the button" turned out to be page confusion: the
   shutdown-prep panel lives on the per-node detail page
   (/v2/nodes/<name>), not the root Fleet Health landing page. Added
   a small "view detail · maintenance & shutdown prep →" hint to the
   bottom of every NodeCard so it's discoverable without already
   knowing to click through.

Verified against tank, architect, and morpheus -- morpheus's
shutdown-prep --dry-run report is now clean (no "command not found"
lines) both when run locally and via cross-node RPC from tank.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 17:03:02 -07:00
osobhandClaude Sonnet 5 4ea1cbed2e Add shutdown-prep button to dashboard-v2 NodeDetail
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Wires safe-shutdown-prep.sh into the dashboard so an operator can
prep a node for hardware maintenance from a browser instead of SSH.

New RPC methods (0x20/0x21):
- ShutdownPrepCheck runs `--dry-run` to completion and returns the
  full report. Never stops anything, safe to call repeatedly.
- ShutdownPrepExecute starts the real run detached (`systemd-run
  --user --scope --collect`), placing it in a cgroup outside
  claw-store.service's own -- the script's own step 6 stops that
  service, i.e. the process that would otherwise be running it, so
  it has to survive its own parent dying. Returns immediately with
  a "started" message; full output lands in
  /var/lib/claw-store/shutdown-prep.log for whoever's at the machine
  once it's gone dark, since there's no way to stream a live result
  past the point the daemon stops itself.
- Execute double-checks confirm_node_name against the peer's own
  configured name server-side, on top of the aggregator's own path
  match -- defense in depth for a highly consequential action.

Aggregator endpoints (admin-token gated, AuthedCaller::require_admin):
  POST /api/v2/node/:name/shutdown-prep/check
  POST /api/v2/node/:name/shutdown-prep/execute

Frontend: ShutdownPrepPanel on NodeDetail. Check button always
enabled; the real "stop services" button only unlocks after a ready
check, and additionally requires typing the exact node name to
confirm before it's clickable.

Also fixes a script bug found while testing this against the live
daemon process (not caught in manual interactive-shell testing): the
zpool-detection line parsed raw `mount` output positionally, which
returned the wrong field under the daemon's process context for
reasons that didn't reproduce interactively. Switched to
`df --output=source`, which is stable across both.

Verified end-to-end against tank, architect, and morpheus, including
cross-node targeting (tank's dashboard successfully triggered a
check on morpheus over the fleet RPC layer).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 14:46:57 -07:00
osobh f30ea04ab6 Merge pull request 'Phase 9 R1: repo-ensure RPC + fleet-health fixes' (#107) from phase-9-r1a-repo-ensure-rpc into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-31 20:05:11 +00:00
osobhandClaude Sonnet 5 bb24c77676 Include self in dashboard-v2 fleet aggregation
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
V2State::from_config built its peer fan-out list solely from
[[cluster.peers]], which by definition never includes the local
node. Result: hitting a given node's /api/v2/fleet directly always
omitted that node from its own fleet view, even when perfectly
healthy -- looked like "node X is missing" from the dashboard when
X just never queried itself.

Fix: synthesize a self PeerEntry from the node's own gossip bind
address and include it in the fan-out, same as any other peer.
peer_rpc_addr()'s existing +1 port convention resolves it to the
same bind_rpc_lan/bind_rpc_tailscale the daemon already listens on.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 13:01:02 -07:00
osobhandClaude Sonnet 5 75d822d0f2 Fail fast when cluster gossip bootstrap fails
Bind failures at startup are almost always a boot-time race against
DHCP/network-online (bind address not yet assigned to the interface).
Previously the daemon caught the error and kept running in a degraded
state with no gossip, RPC, or Prometheus endpoint and no visible
failure signal. Now it propagates the error so the process exits and
systemd's Restart=on-failure retries once the network is actually up.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 11:48:40 -07:00
Omar SobhandClaude Sonnet 4.6 088b88b225 fix(cargo_init): preserve non-[build] sections on activate
strip_section now strips only the [build] block before rewriting it,
so existing [alias] tables and other custom sections survive repeated
activate calls. Adds strip_leading_marker to prevent the managed-marker
comment from accumulating on each re-activation.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-23 06:20:29 +00:00
osobh c3f6b500fc Phase 9 R1: RepoEnsure — peer RPC + aggregator fan-out (#106)
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 25s
2026-07-15 11:19:35 +00:00
Omar Sobh dd2b90872a Phase 9 R1c: daemon wiring + integration test
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
- Wire RpcRouter::with_repo_root at daemon startup in services.rs
  using <blob_store_root>/repos. Nodes with no blob_store_root
  still return NotConfigured (unchanged).
- Add end-to-end ensure→cached→release integration test that seeds
  a bare git repo in a tempdir and exercises the real git-clone
  path. Marked #[ignore] so CI runners without git skip silently;
  runs green locally.
2026-07-15 04:11:58 -07:00
Omar Sobh 5c9bc7eb9c Phase 9 R1b: aggregator fan-out for RepoEnsure/RepoRelease
Layers HTTP over the R1a per-peer primitive so external callers
(clawmates, gitea runners, ops tooling) speak one URL to the
aggregator instead of dialing every peer.

Endpoints (require v2 auth, same middleware as tags/sessions):
  POST /api/v2/repos/ensure   {url, git_ref, workspace?}
  POST /api/v2/repos/release  {url, git_ref, workspace?}

Namespaced tokens are pinned to their own workspace (workspace omitted
in body → derived from token; explicit mismatch → 403). Admin/open
callers must supply workspace explicitly.

Reply shape mirrors the tag fan-out (FanoutReply) with per-peer
{peer, ok, path?, head_sha?, cached?, removed?, error?}. all_ok is
true iff every peer succeeded.

Also adds client wrappers call_repo_ensure/call_repo_release in
cluster/rpc/client.rs used by the aggregator's fan-out.
2026-07-15 04:08:35 -07:00
Omar Sobh 5c1d962bf2 Phase 9 R1a fixup: drop dead if-let wrapping around mkdir 2026-07-15 03:48:24 -07:00
Omar Sobh a3fe1d147c Phase 9 R1a: peer RepoEnsure/RepoRelease RPC (0x1e/0x1f)
New per-peer RPCs to shallow-clone a (url, git_ref) under a caller-
provided workspace namespace, and to release the checkout. Fleet
fan-out via the aggregator ships separately in R1b.

- src/cluster/repo_ensure.rs: request/reply types + derive_path
  (traversal-safe, blake3-hashed url segment), ensure_repo (cache
  hit → rev-parse HEAD, else remove-and-reclone with 5-min timeout),
  release_repo (idempotent rm)
- src/cluster/rpc.rs: Method::RepoEnsure=0x1e, RepoRelease=0x1f,
  RpcRouter::with_repo_root builder, two dispatch arms returning
  NotConfigured when repo_root is unset
- src/cluster.rs: pub mod repo_ensure
- src/actions.rs: fix pre-existing test-only Config init missing the
  aggregator field (unblocks lib tests)

Tests: 6 unit tests covering path derivation determinism, ref/url
independence, traversal safety, and sanitizer edge cases. All pass.
2026-07-15 03:42:27 -07:00
osobh 3b40a94115 Phase 9 S1-S3: TTL-scoped sessions with tag leases (#105)
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-15 07:52:28 +00:00
osobh baefd95427 Phase 9 F4: namespaced tokens for multi-tenant aggregator (#104)
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 2s
2026-07-15 07:16:57 +00:00
osobh 2169e71d54 Phase 9 F1: write-through aggregator API — fleet tag pin/unpin (#103)
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-15 07:07:38 +00:00
Omar Sobh f31c94607a FleetHealth PR 3: 'Projects' panel — which repos live where
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 34s
Backend:
* New DashboardProject { repo, cache_bytes, fingerprint_count,
  refs, first_seen_unix, last_seen_unix, tier }.
* Handler build_projects() joins ref-tracking with the ref-store
  and blob-store: for each recorded fp, resolve fp → blob-id via
  RefStore::{get_stamped, get} then sum blob sizes per repo.
* Tier is a wall-clock function of last_seen_unix:
    active < 24h, recent < 7d, else idle.
* DashboardStorageReply gains `projects: Vec<DashboardProject>`
  (serde-default so older clients still parse).

Aggregator:
* New /api/v2/projects endpoint. Fans out DashboardStorage to
  every peer, tags each project row with its originating node,
  returns hottest-first.

Frontend:
* New ProjectsPanel component appended to the FleetHealth
  landing. Groups by repo, one row per project with tier badge
  (active/recent/idle), per-node pill badges, cache-size sum,
  ref list, last-activity age.
* Empty state explains how to populate: claw-cargo build with
  --repo + --git-ref (or CLAWSTOR_REPO/CLAWSTOR_GIT_REF env
  vars in CI).

Data populates automatically as each cache-put runs. Existing
demo entry on tank (clawverse/clawstor · main · c384a4...) will
surface after redeploy.
2026-07-14 19:06:40 -07:00
Omar Sobh 9d6badf2aa FleetHealth PR 1 (backend): human-oriented DashboardStatus fields
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 17s
Extends DashboardStatus with the primitives the FleetHealth
landing needs. All new fields serde-default to None/[]/false so
old clients (aggregator running an older build) still parse the
reply.

Added fields:
* filesystem  — statvfs on blob_store_root: total / used / avail.
* hot         — {used_bytes, max_bytes, pinned_bytes?}, read from
                gossip (already published every 10s).
* mount       — probes /proc/mounts for ~/clawstor-mount by
                convention. { path, active }.
* cache       — router.metrics().snapshot() summarised as
                {hits, misses, bytes_served, bytes_ingested,
                hit_rate}. hits = get_ref+get_tag+get_chunk hits,
                misses similarly — the counters that matter for
                claw-cargo build outcomes.
* timers      — well-known set (scrub / gc / ref-sweep /
                snapshot-rotate) queried via `systemctl --user
                show`. next_fire_unix + last_result per timer.

Aggregator NodeStatusV2 mirrors the same fields verbatim so the
new frontend can consume them without further backend hops.

Follow-on PRs:
* PR 2 — new FleetHealth landing page + node-detail rework
* PR 3 — polish (pinned_bytes derivation, activity timeline,
         per-repo grouping)
2026-07-14 17:18:45 -07:00
Omar Sobh 1211f0d891 dashboard-v2: DashboardStorage RPC + aggregated /storage/* endpoints
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 18s
Storage tabs 404'd because the aggregator only had /fleet + /node/:name
after the pivot. Adds:

* New RPC method DashboardStorage = 0x1d — one round trip returns
  tags (full), snapshots (full), ref-tracking (full), blobs
  (first 200 by id), refs (first 200 by fp) for the responding
  daemon.
* Client wrapper call_dashboard_storage.
* Aggregator fans out to every peer, tags each row with the
  originating node, sorts + returns:
    GET /api/v2/storage/blobs
    GET /api/v2/storage/tags
    GET /api/v2/storage/refs
    GET /api/v2/storage/snapshots
    GET /api/v2/storage/ref-tracking

QuicClient promoted to Arc<QuicClient> inside V2State so the
per-peer JoinSet can hand it to spawned tasks without recreating
the endpoint.
2026-07-14 16:39:45 -07:00
Omar Sobh a2114b918d dashboard-v2 PR 3: fleet aggregator via DashboardStatus RPC
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 15s
Pivot from per-node dashboard to single-pane-of-glass. The
aggregator (typically the operator's laptop) holds a fleet-CA
leaf cert + the peer list; each dashboard request fans out to
every peer over the existing QUIC/mTLS cluster port and issues
the new DashboardStatus RPC. Peers don't need to run any HTTP
server of their own.

Backend:
* New RPC method DashboardStatus = 0x1c
* Server handler reads BlobStore / TagStore / RefStore /
  SnapshotStore / RefTracking counts + on-disk bytes + rustc
  release. Cheap: 5 filesystem walks per request.
* Client wrapper call_dashboard_status
* serve_v2 rewritten as aggregator: V2State holds a QuicClient +
  peer list from `[[cluster.peers]]`. Endpoints:
    GET /api/v2/fleet             fan-out to every peer, parallel
    GET /api/v2/node/:name/status one peer, on-demand
  Failed peers surface as { online: false, error: "..." } cards
  instead of dropping.

serve.rs graceful degrade: v2 aggregator routes only mount when
[cluster.tls] is set. Static SPA still serves at /v2/* even
without an aggregator config so operators see the SPA's built-in
"config missing" error.

Deployment model (this session):
* Aggregator runs on quantum (Mac) with a signed leaf.
* Fleet daemons run cluster-only — no HTTP dashboard anywhere
  on tank/architect/morpheus. The clawstor-dashboard.service
  systemd units on the fleet are being retired.

Frontend rework to consume /api/v2/fleet ships in the next PR.
2026-07-14 16:19:27 -07:00
Omar Sobh f33468b7c2 dashboard-v2 PR 2: frontend SPA + serve integration
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 16s
React 19 + Vite + Tailwind + wouter (tiny router, no external
state library). Consumes the /api/v2/* endpoints shipped in PR 1.
Serves under /v2/* so the legacy dashboard at / stays live.

Pages:
* CommandCenter (/)      — fleet strip + this-node stat tiles
* NodeDetail (/nodes/:name) — per-node deep dive
* StorageBrowser (/storage/{blobs,tags,refs,snapshots}) — tables
  with prefix filter
* RefTrackingPage (/refs/tracking) — grouped by repo

Backend changes:
* claw-store serve grows --v2-static-dir <path>
* build_app split into build_app_with_v2 for the extra static
  mount
* /v2/* falls through to index.html so wouter client routing works

New systemd unit: clawstor-dashboard.service. Points at both
static dirs; installs on any node.

dashboard/ (legacy) untouched. dashboard-v2/ built to
target/dashboard-v2/dist for deploy.

Deploy sequence per node:
1. cp target/release/claw-store  ~/clawstor-deploy/
2. rsync dashboard-v2/dist/      ~/clawstor-deploy/dashboard-v2/
3. cp deploy/systemd/clawstor-dashboard.service ~/.config/systemd/user/
4. systemctl --user daemon-reload && enable --now clawstor-dashboard.service

Cross-node fan-out for /api/v2/node/:name/status is PR 3.
Action POSTs (scrub/gc/snapshot/pin) are PR 4.
2026-07-14 15:59:47 -07:00
Omar Sobh b431475af7 dashboard-v2 (PR 1): design doc + backend read-only endpoints
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 17s
Kicks off the single-pane-of-glass command-center rewrite. Legacy
/api/* handlers untouched — v2 is additive so cutover is safe.

Doc: docs/dashboard-v2.md — design goals, endpoint spec, cutover
plan.

Backend: new serve_v2 module wired into serve.rs. Endpoints:

  GET /api/v2/node/local/status
  GET /api/v2/node/:name/status         (fan-out: not-yet-impl)
  GET /api/v2/storage/blobs?limit&offset
  GET /api/v2/storage/tags?prefix
  GET /api/v2/storage/refs?limit&offset
  GET /api/v2/storage/snapshots
  GET /api/v2/storage/ref-tracking?repo

V2State opens BlobStore / TagStore / RefStore / SnapshotStore /
RefTracking under the daemon's blob_store_root + the conventional
subdirs (tags-db, refs-db). All handlers are single-node reads;
cross-node fan-out lands in PR 3.

+6 tests: status w/ seeded blob+snapshot, cross-node returns 501,
blobs pagination, tags prefix filter, snapshots list, ref-tracking
repo filter.

395 tests pass. serve.rs merges v2 routes onto the axum router
with shared CORS. Single-shot deploy on tank + architect will
expose /api/v2/* alongside the existing /api/*.

PR 2 = frontend rewrite consuming these endpoints.
PR 3 = fleet fan-out (cross-node aggregation via QUIC RPC).
PR 4 = action endpoints (POST scrub/gc/snapshot/pin).
PR 5 = cutover (deprecate legacy /api/*).
2026-07-14 15:52:47 -07:00
Omar Sobh 9d7e62bcee Polish: enable claw-fuse on macOS via macFUSE + deploy/macos docs
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 15s
Cargo.toml: fuser dep now target-gated to Linux + macOS. macOS
build requires macFUSE (brew install --cask macfuse) + pkg-config
before 'cargo build --features fuse' works.

deploy/macos/README.md — one-time prereqs, build steps, mount /
umount, known differences (no AllowOther, unmount is 'umount'
not fusermount3 -u).

deploy/macos/claw-fuse.plist — launchd agent template
(RunAtLoad + KeepAlive) so ghost / macbook / smith can run the
mount the same way tank/architect do under systemd.

Default 'cargo build' (no --features fuse) still works on macOS
with no macFUSE installed — feature gate keeps the dep opt-in.
2026-07-14 13:16:17 -07:00
Omar Sobh 0e5e5982d8 Polish: extract src/lib.rs so bins reuse a proper library crate
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s
Historically every bin (claw-store, claw-cargo, claw-fuse)
re-declared the same 13-line 'mod ...' block at its own root.
Working but fragile — a new bin (or a new module) needed edits
in N+1 places and stayed one edit-slip away from silently
dropping something.

Now: src/lib.rs owns the shared module tree. Cargo.toml gets a
[lib] entry so cargo picks it up. claw_fuse.rs migrated as the
proof-of-concept — 13 mod lines → 4 use lines.

claw-store + claw-cargo bins still use their internal 'mod ...'
blocks and 'crate::...' paths — migrating them is mechanical
but noisy; deferred to a follow-on so this PR stays reviewable.
Both bins + tests continue to build unchanged.

387 tests pass.
2026-07-14 13:14:14 -07:00
Omar Sobh 1c5d3c08bd Polish: cluster-ref-sweep --apply forgets stale records
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Default remains dry-run. --apply iterates the stale set and calls
RefTracking::forget per fp. Blob eviction stays a separate step
(next cluster-gc). Errors are surfaced per-fp, batch continues.
2026-07-14 13:11:04 -07:00
Omar Sobh 5dd5d2d00f Polish: tests for Phase 6c TagStore fixes + RefStore::list
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Retroactive coverage for the layer-split fixes shipped live in
Phase 6c hotfix cycle:
* TagStore::list unions legacy+stamped, dedups by key
* TagStore::get falls through to stamped when legacy absent
* TagStore::delete unlinks both layers + TTL sidecar
* TagStore::contains reports stamped-only pin
* RefStore::list empty + sorted-pairs (stamped wins on collision)

+6 tests. 387 pass.
2026-07-14 13:09:06 -07:00
Omar Sobh 8a502be65c Polish: cargo fix pass (auto-remove unused imports)
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
2026-07-14 13:06:49 -07:00
Omar Sobh 6d16d30eee Phase 6e hotfix: RefStore lives at <data>/refs-db/ not <data>/
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Daemon opens refs under <blob_root>/refs-db/ (see services.rs).
FUSE was opening at <data>/ and finding nothing. Match the
daemon's nested convention.
2026-07-14 12:44:41 -07:00
Omar Sobh 7f46e2566b Phase 6e: expose refs as FUSE files
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s
<mount>/refs/<fp-hex>   file, content = blob that fp points at

Adds RefStore::list() unioning legacy refs/ and stamped refs-v2/.
FUSE wires the new dir. On lookup, tries get_stamped first then
legacy — matches the resolution order used by claw-cargo build.

Ref files piggy-back the blob inode (same content), so a hex
readable via /blobs/<hex> and /refs/<fp> shares the inode. cheap.
2026-07-14 12:37:20 -07:00
Omar Sobh cf8acadbc1 Phase 6c fix: hide companion tags from FUSE
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
Companion tags store a fingerprint, not a blob-id. Filter them
out of /tags so ls does not show unreadable entries.
2026-07-14 12:32:18 -07:00
Omar Sobh 2c51f0917e Phase 6c fix: TagStore delete()/contains() also see stamped tags
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Companion to the previous list/get fix. Same layer-split problem:
* delete() only unlinked tags/, leaving stamped tags-v2/ behind.
  Result: `unpin` prints \"no such tag\" for pins created via
  Phase 3c+ RPC even though the tag is right there on disk.
* contains() only checked tags/. Same false-negative.

Fix: both APIs now inspect BOTH layers. delete() unlinks
whichever files exist (either or both) AND removes the TTL
expiry sidecar if present. Returns true when anything was
actually removed.

Legacy behavior preserved: tests unchanged, 381 tests pass.
2026-07-14 12:27:36 -07:00
Omar Sobh 14a8221e00 Phase 6c fix: TagStore list()/get() see stamped tags
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 12s
Live smoke exposed the gap: `claw-cargo pin` writes to the
stamped store (tags-v2/, Phase 3c+) but `TagStore::list()` +
`get()` only walked the legacy `tags/` layer. Modern pins were
invisible to everything using those APIs — including the new
FUSE tag layer, but also `claw-cargo list-tags`.

Fix:
* list() now unions legacy + stamped entries (deduped by key).
  New private list_stamped_only() walks tags-v2/.
* get() falls through to get_stamped() when the legacy file is
  absent — modern pins resolve without callers knowing which
  layer stored them.

No API breakage: legacy tests still pass unchanged.
381 tests pass.
2026-07-14 12:25:15 -07:00
Omar Sobh 3cb32f134a Phase 6c: tags as files in claw-fuse
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
<mount>/tags/<sanitized-name>   ← file, content = current tag's blob

Layers atop the Phase 6a/6b mount. Operator can now `cat` a named
build cache without translating a tag → blob-id first:

  cat <mount>/tags/clawverse:main:latest-cache | tar -tvzf -

Slash → underscore for filenames (tag keys like `a/b:c` land as
`a_b:c`), tags with control chars or NUL are dropped from the
listing. Non-existent value blobs also drop.

Tag file inodes 1_000..9_999. Each getattr / read re-resolves
the tag key (they're mutable — a `pin --replace` under a tag
should show the new blob without unmount).

Reused alloc_blob_ino so tag reads share inodes with /blobs/<hex>
where possible. TagStore opened at `<data_dir>/tags-db` matching
the daemon's convention.
2026-07-14 12:22:15 -07:00
Omar Sobh 6f12bd908a Phase 6b: snapshots as directories in claw-fuse
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 18s
Extends the read-only FUSE mount with a `snapshots/` tree:

  <mount>/snapshots/<name>/               ← dir per snapshot
  <mount>/snapshots/<name>/<blob-id-hex>  ← file, content = assembled blob

Lets an operator browse a point-in-time capture by name — `ls`,
`find`, `sha256sum` all just work.

Implementation:
* Inode partitioning: 1 = /, 2 = /blobs, 3 = /snapshots,
  10_000..99_999 = snapshot dirs (lazy allocation), 100_000+
  = blob files (shared with the /blobs tree — same blob has
  the same inode whether reached via /blobs or /snapshots/<n>).
* lookup on /snapshots/<name> validates the snapshot exists via
  SnapshotStore::get.
* lookup on /snapshots/<name>/<blob-hex> validates both that
  the snapshot references that blob AND that the blob is on
  disk — no stale symlinks.
* Reused the shared alloc_blob_ino helper so the /blobs and
  /snapshots trees hand out identical inodes for the same blob.

No new tests: FUSE is integration-heavy and the underlying
snapshot + blob primitives are already covered.
2026-07-14 12:18:22 -07:00
Omar Sobh 35848d6cf7 Phase 6a hotfix: claw-fuse needs full mod list
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Missed snapshot/sync/zfs mods → build failed on tank with
'unresolved import' errors. Rest of the code was fine.
2026-07-14 12:15:31 -07:00
Omar Sobh c678c08c76 Phase 6a: read-only FUSE mount over the blob store
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
First slice of Phase 6. Ships a minimal, feature-gated `claw-fuse`
binary that mounts the local blob store read-only as a POSIX
filesystem:

  <mount>/blobs/<blob-id-hex>   ← file, content = assembled blob
  <mount>/blobs/                ← dir, ls shows all blob-ids
  <mount>/                      ← dir, contains `blobs`

Lets an operator `tar -tvzf`, `md5sum`, or grep at a cached
tarball without wiring a client. Debug + audit tool for now;
warm-tier git-worktrees + write path come in later slices.

Feature-gated so my macOS dev box doesn't need macFUSE headers
to build the rest of the tree:
* Cargo.toml declares `[[bin]] name = "claw-fuse"` with
  `required-features = ["fuse"]`.
* Feature `fuse` pulls in `fuser = "0.15"`, target-restricted
  to `cfg(target_os = "linux")` — dep resolution never
  considers fuser on other platforms.
* `cargo build` (default) leaves claw-fuse out entirely.
  `cargo build --features fuse --bin claw-fuse` on Linux builds it.

Design notes baked into the impl:
* Inode allocation is lazy — first `lookup` for a hex assigns an
  inode. Avoids pre-indexing the full blob store at mount time
  which would be O(blobs) fs walk before FUSE is even ready.
* getattr / read validate that the manifest exists on every
  call — no stale-inode reads if a blob is GC'd out from under
  us mid-mount. Extra read cost is negligible against the
  per-request FUSE overhead.
* size = manifest.total_size (bytes reported without touching
  chunk files) so `ls -l` is cheap.
* runtime = current-thread tokio, block_on per callback. fuser
  is sync; a full tokio worker pool would just add scheduling
  overhead when callbacks are already serialized by the kernel.

No new tests here — Filesystem impls are integration-heavy and
the underlying BlobStore methods are already covered. The
`fuse` feature build itself will be smoke-tested on tank.

381 tests pass unchanged (feature-gated bin doesn't affect the
existing test surface).
2026-07-14 12:13:59 -07:00
Omar Sobh cf12554128 Phase 8e: cluster-ping migrates to connect_lan_first
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 12s
Same shape as Phase 8c did for cluster-peer-status + cluster-repair.
New flags: --tailscale-addr (optional) + --lan-probe-ms (default 200).
Route (LAN vs tailnet) printed on the output. Zero flag = identical
to pre-8 single-addr behavior.

Fourth of four operator-facing CLIs now routing-aware
(cluster-peer-status, cluster-repair, cluster-ping done; cluster-ping
was the last outstanding one).

No new tests: pure glue over connect_lan_first, which has its own
unit coverage.
2026-07-14 12:10:54 -07:00
Omar Sobh fa863fdc7d Phase 7e: claw-cargo smart-clean — 3 local cleanup modes
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Reclaims local target-dir disk in increasing bluntness. All
modes are LOCAL only — the fleet blob cache is untouched, so
`claw-cargo build` after smart-clean restores from peer.

Modes:
* incremental-only — remove target/*/incremental/ across all
  profiles. Safest; keeps final artifacts + deps.
* soft (default) — remove target/ entirely. Blob still on peer.
* hard — soft, but requires --force. Reserved for operators who
  know their build is transient. Rejected without --force even
  in --dry-run so the safety belt can't be trained away.

--dry-run reports paths + byte count without touching disk.

New helpers (unit-tested in isolation):
* find_incremental_dirs(target) — walks target/*/incremental,
  returns only existing entries.
* dir_size_bytes(root) — recursive byte count, silent on read
  errors (used only for reporting, not correctness).

+5 tests: incremental discovery (existing only), missing target
empty, byte sum recursive, missing dir returns 0, hard-without-
force rejects.

381 tests pass (+5). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 12:08:43 -07:00
Omar Sobh dbc1587bcb Phase 8d: daemon binds a second QuicServer on the tailnet interface
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Live smoke on tank↔architect exposed the gap: bind_rpc_tailscale
was being *advertised* via gossip so peers learned to dial it,
but the daemon never actually LISTENED there. Tailnet dials hit
a closed port.

Fix: when both bind_rpc_lan and bind_rpc_tailscale are set (and
differ), spawn a second QuicServer on the tailnet address. Shares
the same fleet-CA identity + RpcRouter as the LAN listener —
requests from either side hit the same handlers.

If the second bind fails (e.g. tailnet interface not up), we log
a warning and keep the LAN listener alive rather than aborting
daemon startup. Standard graceful-degrade shape.

No new tests here — a live integration test would need two
network interfaces + a running tailscale, which the CI runners
don't have. Coverage happens on the tank+architect deployment:
`ss -lunp` on architect must show TWO clawstor UDP listeners
after this change (10.0.0.13:7702 + 100.104.171.32:7702).

Follow-on: cert SAN for the tailnet address. The current
fleet-CA-signed leaf only has the node name as SAN, so rustls
verification on the client side still checks against
--peer <name> which passes because CN == node name. But a
belt-and-suspenders leaf using fleet-ca-tailscale-sign (Phase
8a) would be more correct.
2026-07-14 12:00:06 -07:00
Omar Sobh f2c056464c Phase 8c hotfix: skip probe deadline when no fallback exists
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s
Live smoke on tank↔architect (both LAN) failed with 200ms probe:
LAN handshake takes longer than that in the wild (TLS 1.3 with
full cert chain + rustls startup on fresh endpoint). The old
single-addr .connect() had no deadline, so pre-8c callers never
noticed.

Fix: when `tailscale` is `None`, treat LAN as unlimited — the
probe deadline only matters as a fall-through trigger, and
there's nothing to fall through to. Callers with a real fallback
addr still get the fast-path routing behavior unchanged.

+1 test (connect_lan_first_lan_only_ignores_probe_deadline)
using a 1-nanosecond probe budget that a real handshake could
never meet — must succeed anyway because no fallback exists.

376 tests pass (+1).
2026-07-14 11:45:29 -07:00
Omar Sobh 38652c5886 Phase 8c: cluster-peer-status + cluster-repair support --tailscale-addr
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Wires the operator CLIs to the Phase 8b connect_lan_first primitive.
Roaming ops (laptop on LTE, coffee-shop wifi) can now pass a
tailnet address alongside the usual --rpc-addr and get the
LAN-first-with-fallback behavior automatically.

New flags on both cluster-peer-status and cluster-repair:
* --tailscale-addr <addr>   — optional tailnet RPC socket. When
                              set, --rpc-addr is tried first with
                              a short deadline, then this on
                              failure/timeout.
* --lan-probe-ms <ms>       — LAN probe deadline. Default 200
                              matches the arch doc.

Zero flag → byte-identical to pre-8c behavior (single-addr dial).
Both flags → chosen route printed in the output header so
operators can see whether LAN or tailnet won.

No new tests: this is thin glue over connect_lan_first, which
already has its own unit coverage. Smoke test live on tank
against architect (LAN), and against fake unroutable + real
tailnet exercises both branches.
2026-07-14 11:18:35 -07:00
Omar Sobh ef60a7984e Phase 8b: LAN-first probe with tailnet fallback
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s
Second Phase 8 slice. Prior transport.connect() took a single
address; the LAN-first-then-Tailscale routing the arch doc calls
out was implicit ("pick lan_addr OR tailscale_addr from gossip
state") and never actually raced or fell through.

New: QuicClient::connect_lan_first(name, lan, tailscale, lan_probe)
* Try LAN first with `lan_probe` deadline (fleet default ~200ms).
* If LAN handshake fails OR the deadline fires → fall back to
  the Tailscale address.
* Both slots None → error immediately (no hang).

Returns (connection, ConnectRoute) so callers + telemetry see
which side won. New enum ConnectRoute::{Lan(addr), Tailscale(addr)}.

+3 tests exercising the three shapes:
- lan-first when LAN reachable (never dials fake tailscale addr)
- fallback when LAN black-holes (240.0.0.1 SYN gets no response;
  probe deadline fires, tailscale server wins)
- errors cleanly when both addrs absent

375 tests pass (+3). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.

Follow-ons for Phase 8 completion:
- Wire the peer-connect call sites (RPC forwarding, PeerStatus,
  build-cache) through connect_lan_first with per-peer
  lan/tailscale addrs from gossip state.
- Document the roaming-client config template.
2026-07-14 11:12:19 -07:00
Omar Sobh 98036f2597 Phase 8a: fleet-ca-tailscale-sign — Tailscale-aware leaf certs
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 25s
First slice of Phase 8 (roaming client identity). Adds a helper
that mints a leaf cert whose SANs include this node's Tailscale
identity — MagicDNS name (laptop.taila4f562.ts.net) + all tailnet
IPs — alongside the primary node name.

Closes the "how does a laptop join the fleet without hand-editing
SANs" gap: on a machine that's on Tailscale, one command produces
a leaf that peers can dial by MagicDNS from anywhere on the
tailnet.

New CLI:
  claw-store fleet-ca-tailscale-sign \
    --ca-dir /etc/claw-store/ca \
    [--node <name>]           # defaults to Tailscale HostName
    --out-dir /etc/claw-store/tls

Reads identity by shelling to `tailscale status --json` (already
present on any node that's on the tailnet; no extra dep). If
tailscale isn't running or installed, exits cleanly with a real
error.

New module cluster::tailscale:
* TailscaleSelf { magicdns_name, tailscale_ips, short_hostname }
* read_self() — runs the CLI, returns identity
* parse_status() — pure decoder, unit-tested
* suggested_sans() — MagicDNS + IPs ordered for the CA sign flow

FleetCa additions:
* sign_leaf_to_pem_with_sans(node_name, extra_sans, out_dir) —
  Sans-extended variant of sign_leaf_to_pem. Empty entries dropped.
  Existing sign_leaf_to_pem now delegates with empty extras (100%
  backward compat).
* mint_leaf_with_sans — internal shared helper.

+5 tests: parse full identity, parse missing MagicDNS, error on
no Self record, suggested_sans ordering, suggested_sans skips
missing MagicDNS.

372 tests pass (+5). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.

Next Phase 8 slices: (a) tailnet-preferring peer probe with a
config-selectable auth mode, (b) documented "roaming client"
config template.
2026-07-14 11:08:57 -07:00
Omar Sobh 3af6390316 Phase 7f: claw-cargo auto-records fingerprint → (repo, git_ref)
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Closes the ref-tracking loop. claw-cargo build now records the
producing (repo, git_ref) alongside every cache-put fingerprint,
so cluster-ref-sweep can identify stale entries later without
operator bookkeeping.

New BuildArgs flags:
* --repo <owner/name> (env CLAWSTOR_REPO, or GITEA_REPOSITORY /
  GITHUB_REPOSITORY when the CI runner sets them via that name
  in workflow env)
* --git-ref <branch-or-tag> (env CLAWSTOR_GIT_REF)
* --ref-tracking-dir <path> (env CLAWSTOR_DATA_DIR, typically
  /var/lib/claw-store/data — same root as cluster.blob_store_root)

Semantics:
* All three unset → silently skipped. Existing cache flows are
  unchanged.
* dir doesn't exist or open() fails → logs warn, cache still valid.
* record() call fails → logs warn, cache still valid.

The tracking store is co-located with the daemon's data dir so
cluster-ref-sweep on that host sees the annotations. Runners
mount /var/lib/claw-store/data via bind-mount today.

No new tests here — the primitive (RefTracking::record) already
has full coverage. This is thin glue.
2026-07-14 11:01:15 -07:00
Omar Sobh 7bc5ba987c Phase 7f follow-on: Gitea live-refs adapter + cluster-ref-sweep CLI
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 6s
Wires the Phase 7f ref-tracking primitives to a real Gitea. New
CLI `claw-store cluster-ref-sweep --gitea-url <> [--gitea-token]
[--retention-days N]` queries every distinct repo we've recorded
against, fetches its live branches + tags, computes the stale set
via RefTracking::stale_at, and prints the stale fingerprints
grouped by repo.

Dry-run only in this cut. Deletion is separate — the operator
decides whether to call `forget` per fp, and whether to also
prune the corresponding blob/tag. Blob eviction happens via
cluster-gc as usual (dead refs no longer contribute to any pin).

New module cluster::gitea:
* GiteaClient::new(base_url, token) — reqwest with 15s timeout,
  rustls-tls (reuses the rustls stack quinn already pulls in).
* live_refs(repo) — fetches /branches + /tags concurrently,
  paginated (page 200 hard cap for safety), returns HashSet.
* 404 on either endpoint returns empty set — deleted repos then
  flow through stale_at as "all refs dead", the correct default.

Deps:
* reqwest 0.12 with rustls-tls + json, default-features off (no
  native-tls / openssl chain).
* clap 4 + "env" feature so --gitea-token can read GITEA_TOKEN.

+2 tests (validate_repo shape, client trims trailing slash).
Full test suite: 367 pass (+2). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 10:58:27 -07:00
Omar Sobh 294697d2f5 Phase 7f: ref-tracking primitives for retention-eligibility
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
Records which (repo, git-ref) combinations produced each cache
fingerprint. Later slices will wire this to a nightly Gitea sweep
that queries /api/v1/repos/.../branches and /tags, then evicts
fingerprints whose recorded refs are all gone AND whose
last_seen_unix is older than the retention window.

Per-fingerprint (not per-blob) because:
* Fingerprints are the cache keys claw-cargo uses. Tracking at the
  fp layer keeps this aligned with the claw-cargo boundary.
* Blobs are content-addressed and may be shared. Ref-tracking is
  about "why we kept this cache" — a per-fp concern.

New module cluster::ref_tracking:
* RefEntry { fingerprint, repo, refs, first_seen_unix, last_seen_unix }
* RefTracking::record(fp, repo, git_ref, now) — creates or updates
* RefTracking::get(fp) / list_all() / forget(fp)
* RefTracking::stale_at(now, live_refs_by_repo, retention_secs) →
  Vec<fingerprint>, the deletion-eligibility list

On-disk: <root>/ref-tracking/<hh>/<fp_hex>.json. JSON so operators
can inspect with jq. One record per cached fp; even 100k fps is
under 50 MB.

Semantics baked in:
* record() APPENDS refs, never removes — sweep decides staleness
* record() rejects repo change for a fp (collision or bug detector)
* refs stable-sorted in-file so cross-node diff is easy
* stale_at treats "repo not in live_refs map" as "all refs dead"
  → deleted repos don't leak caches
* retention_secs is a floor: dead-but-fresh caches survive

+10 tests: create, append-and-refresh, dedup, repo-change reject,
stale-at happy path, stale-at missing-repo, stale-at retention,
forget truth values, list sorted, validate rejects.

365 tests pass (+10). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
No CLI or wire integration in this PR — pure library, testable
in isolation. Follow-ons: (a) claw-cargo auto-record on cache put,
(b) Gitea polling adapter, (c) sweep wired into cluster-gc.
2026-07-14 10:52:04 -07:00
Omar Sobh eebc62d87b Phase 7d follow-on: snapshots pin blobs against LRU eviction
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
Closes the retention loop between snapshots and pin-aware LRU
eviction. A snapshot is not just a "list of blobs at time T" any
more — it's a *retention pin* on every blob it captures.
Operators can guarantee a build stays on disk for N days by
snapshotting it and pruning the snapshot when the window is up.

Additions:
* SnapshotStore::pinned_blob_ids() → union of blob_ids across all
  live snapshots. Cheap: one JSON read per snapshot.
* cmd_cluster_gc extends the tag-pin set with snapshot pins
  before handing it to evict_to_size_cap_with_pins. Output line
  now reads "pinned blobs: N (M from snapshots)".
* ClusterServices auto-GC ticker does the same on every tick;
  log fields include snapshot_pins so ops see the retention set
  size at a glance.

+2 tests:
- pinned_blob_ids_unions_all_snapshots (overlap dedupe)
- pinned_blob_ids_empty_when_no_snapshots

355 tests pass (+2). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 09:30:31 -07:00
Omar Sobh e564b0ce89 Phase 7d: snapshot primitives + CLI
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 10s
A snapshot is a named, immutable point-in-time record of every blob
live in the store. It's NOT a data copy — blobs are content-addressed
and already live under blobs/. A snapshot is a JSON reference set at
<root>/snapshots/<name>.json.

Why:
* Rollback anchor before risky migrations.
* Retention pin: combined with the Phase 4a pin-aware LRU eviction,
  operators can guarantee "these blobs stay on disk N days".
* Audit: "which blobs existed at release time?"

New module cluster::snapshot:
* SnapshotStore::create(name, blob_store, created_at)
* SnapshotStore::get(name) / list() / delete(name)
* SnapshotManifest { name, created_at_unix, blob_ids }
* SnapshotSummary for cheap list rendering (no blob-list slurp).

BlobStore gains list_blob_ids() — walks blobs/**/*.manifest.json
and returns the blob id set. Manifests only, no chunk reads.

New CLI commands:
* claw-store cluster-snapshot-create --name <>
* claw-store cluster-snapshot-list
* claw-store cluster-snapshot-show --name <>
* claw-store cluster-snapshot-delete --name <>

Semantics:
* Snapshots are immutable: create with existing name errors, does
  not clobber. Delete-then-create if you really want to overwrite.
* delete() removes only the reference file. Never touches blob
  data — protects against operators nuking live data by pruning
  snapshots.
* list() sorts by created_at_unix ascending — oldest first so
  triage picks pruning candidates quickly.
* blob_ids are sorted at write time so the same content on two
  nodes yields byte-identical snapshot files.
* Names validated: no /, \\, NUL, control chars; max 512 bytes.

+8 tests covering create+capture, immutability, get-missing,
list-ordering, delete truth-values, delete-doesn't-touch-blobs,
name-validation, and sorted round-trip.

353 tests pass (+8). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 09:26:47 -07:00
Omar Sobh da198c0903 Phase 7c: cluster-repair CLI wires repair to a peer
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
New command: `claw-store cluster-repair --peer <name> --rpc-addr <host:port>
--tls-dir <dir> [--dry-run]`.

Flow:
1. Local scrub identifies bad chunks (missing + corrupt).
2. Deduplicate to unique chunk-hashes (scrub emits per-reference,
   fetcher work is per-chunk).
3. Connect to peer over QUIC + mTLS.
4. For each unique chunk: HasChunk probe → GetChunk on hit →
   put locally (re-hashed by put_chunk, so a lying peer can't
   corrupt us further).
5. Report attempted/repaired/unrecoverable/errors.

--dry-run stops after the dedup step: prints the plan without
touching the peer or disk.

Behavior details:
* Zero bad chunks → clean exit with no peer contact.
* Any unrecoverable or per-chunk error → non-zero exit so cron/CI
  notice. Message names counts.
* HasChunk-first means a peer that lacks the chunk is one cheap
  round-trip, not a full GetChunk attempt.

Companion piece for the Phase 7b repair library (already merged).
No new tests here — logic is thin glue over `repair_chunks` +
`call_has_chunk`/`call_get_chunk`, all of which have their own
unit + integration coverage. Behavior gets its real workout in
live smoke on tank+architect.
2026-07-14 09:21:55 -07:00
Omar Sobh cf0a07099d Phase 7b: chunk-level repair library
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
New primitive: BlobStore::repair_chunks(chunks, fetch) → RepairReport.

Consumer flow: cluster-scrub returns a list of (blob, chunk) bad
pairs. cluster-repair (next slice) will hand the chunk hashes here
with a fetcher that walks peers via HasChunk/GetChunk. This PR is
the library-only half — no peer wiring — so it's testable in
isolation and reusable by callers who already have a chunk source.

Fetcher contract:
* Ok(Some(bytes)) → put locally, count repaired
* Ok(None)        → nobody has it, record as unrecoverable
* Err(e)          → per-chunk error, batch continues

Guardrails:
* Bytes are re-hashed by put_chunk before writing. A peer that
  returns wrong bytes for a hash cannot corrupt us further.
* Duplicate chunk hashes in the input dedupe → fetcher called
  exactly once per unique chunk. Matters because scrub reports
  shared chunks once per owning manifest.
* Errors on one chunk never abort the batch — the remaining
  chunks still get their shot.
* Repair overwrites a corrupt file: unlink-then-put_chunk, since
  put_chunk itself is write-if-absent. NotFound on unlink is fine
  (missing-chunk case).

+4 tests:
- repair_writes_fetched_bytes_and_marks_repaired (happy: corrupt
  → repair → post-scrub clean)
- repair_records_unrecoverable_when_fetcher_returns_none
- repair_records_error_and_continues_batch (batch survives one
  chunk's error)
- repair_dedups_duplicate_chunks_in_input (fetcher called exactly
  once for 3 identical hashes)

345 tests pass (+4). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 08:44:00 -07:00
Omar Sobh 701861787f Phase 7a: read-only fsck for the blob store
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 12s
New primitive: BlobStore::scrub_all() → ScrubReport.

Walks every .manifest.json under blobs/, for each referenced chunk
reads the file from disk and recomputes BLAKE3. Verdict per chunk:
* file absent → missing
* hash mismatch → corrupt
* match → ok

Design points:
* Read-only. Never touches disk state. Safe against a live daemon
  — worst case a chunk lands mid-scrub and is skipped this pass.
* Per-reference counting: a bad chunk that N manifests depend on
  shows up as N corrupt entries so operators see the full blast
  radius. But each unique chunk is hashed exactly once via an
  in-memory verdict cache.
* Report holds explicit (blob_id, chunk_hash) pairs for every
  bad chunk so the fix path (repair in Phase 7b) has enough
  info to act.

CLI: `claw-store cluster-scrub [--verbose]`. Non-zero exit when
integrity issues exist so cron / CI notice.

+4 tests:
- scrub_reports_all_ok_when_store_is_healthy
- scrub_detects_corrupt_chunk (owner blob id preserved)
- scrub_detects_missing_chunk (owner blob id preserved)
- scrub_dedups_shared_chunk_hashing_once (shared chunk, 2 owners
  reported, single disk read)

341 tests pass (+4). Pre-existing macOS
hot::tests::test_project_target_size_bytes failure unchanged.
2026-07-14 08:39:58 -07:00