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
This commit is contained in:
Omar Sobh
2026-07-12 00:07:33 -07:00
parent 57d8358255
commit b7904b59a5
7 changed files with 1435 additions and 492 deletions
+589
View File
@@ -0,0 +1,589 @@
//! Client-side RPC helpers split out of rpc.rs to keep the parent
//! module under the 1300-line ceiling. Every function here calls
//! [`super::rpc_call`] or opens a bidi stream directly and follows
//! the wire format documented on [`super::Method`].
//!
//! Re-exported through `pub use client::*;` in the parent so external
//! callers keep the `cluster::rpc::call_*` paths they've been using.
use super::*;
// ── Streaming client helpers ─────────────────────────────────────────
/// Upload a blob by streaming from `reader` into the peer's store.
/// Never buffers the whole blob — memory usage is dominated by
/// tokio's default copy buffer. Returns the peer-assigned BlobId,
/// which is NOT locally re-verified (the peer doesn't get the whole
/// content in memory at once so we can't cheaply hash from our side);
/// callers who need cryptographic proof of end-to-end integrity
/// should follow up with `call_blob_stat` and compare hashes.
pub async fn call_blob_put_stream<R>(
conn: &Connection,
mut reader: R,
) -> Result<BlobId>
where
R: AsyncRead + Unpin,
{
let (mut send, mut recv) = conn
.open_bi()
.await
.context("opening bidi stream for BlobPutStream")?;
send.write_all(&[Method::BlobPutStream.as_byte()])
.await
.context("writing method tag")?;
tokio::io::copy(&mut reader, &mut send)
.await
.context("streaming blob content to peer")?;
send.finish().context("finishing BlobPutStream send")?;
let mut status = [0u8; 1];
recv.read_exact(&mut status)
.await
.context("reading BlobPutStream status byte")?;
if let Some(code) = decode_error(status[0]) {
bail!("peer replied with error: {}", code.describe());
}
if status[0] != STREAM_STATUS_OK {
bail!(
"peer replied with unknown status byte 0x{:02x}",
status[0]
);
}
let mut id_bytes = [0u8; 32];
recv.read_exact(&mut id_bytes)
.await
.context("reading BlobId from BlobPutStream reply")?;
Ok(BlobId::from_bytes(id_bytes))
}
/// Fetch a blob by streaming its bytes into `writer`. Returns
/// `Ok(false)` when the peer replies [`ErrorCode::NotFound`]; other
/// error codes surface as `Err`. `writer` receives exactly the blob
/// content — no leading status byte, no framing.
pub async fn call_blob_get_stream<W>(
conn: &Connection,
id: &BlobId,
writer: &mut W,
) -> Result<bool>
where
W: AsyncWrite + Unpin,
{
let (mut send, mut recv) = conn
.open_bi()
.await
.context("opening bidi stream for BlobGetStream")?;
let mut req = Vec::with_capacity(33);
req.push(Method::BlobGetStream.as_byte());
req.extend_from_slice(id.as_bytes());
send.write_all(&req)
.await
.context("writing BlobGetStream request")?;
send.finish().context("finishing BlobGetStream send")?;
let mut status = [0u8; 1];
recv.read_exact(&mut status)
.await
.context("reading BlobGetStream status byte")?;
if let Some(code) = decode_error(status[0]) {
if code == ErrorCode::NotFound {
return Ok(false);
}
bail!("peer replied with error: {}", code.describe());
}
if status[0] != STREAM_STATUS_OK {
bail!(
"peer replied with unknown status byte 0x{:02x}",
status[0]
);
}
tokio::io::copy(&mut recv, writer)
.await
.context("streaming blob content from peer")?;
writer
.flush()
.await
.context("flushing destination after BlobGetStream")?;
Ok(true)
}
// `dispatch` is a server-side helper — it lives back in rpc.rs alongside
// `serve_connection`. Tests reach it via `super::dispatch`.
/// Client-side: open a bidi stream, write `method || payload`, read
/// reply. Returns the raw reply bytes; callers deserialise per method.
pub async fn rpc_call(
conn: &Connection,
method: Method,
payload: &[u8],
) -> Result<Vec<u8>> {
if payload.len() + 1 > MAX_MESSAGE_BYTES {
bail!(
"RPC payload {} bytes (+1 for method tag) exceeds cap {}",
payload.len(),
MAX_MESSAGE_BYTES
);
}
let (mut send, mut recv) = conn
.open_bi()
.await
.context("opening bidi stream for RPC")?;
let mut buf = Vec::with_capacity(1 + payload.len());
buf.push(method.as_byte());
buf.extend_from_slice(payload);
send.write_all(&buf).await.context("writing RPC request")?;
send.finish().context("finishing RPC send stream")?;
let reply = recv
.read_to_end(MAX_MESSAGE_BYTES)
.await
.context("reading RPC reply")?;
Ok(reply)
}
/// Convenience wrapper for [`Method::Ping`]. Sends `payload`, returns
/// the peer's echo (`"pong:" || payload`) with the prefix stripped.
pub async fn call_ping(conn: &Connection, payload: &[u8]) -> Result<Vec<u8>> {
let reply = rpc_call(conn, Method::Ping, payload).await?;
if reply.len() == 1 {
if let Some(code) = decode_error(reply[0]) {
bail!("peer replied with error: {}", code.describe());
}
}
if let Some(rest) = reply.strip_prefix(b"pong:") {
Ok(rest.to_vec())
} else {
bail!(
"peer replied with unexpected shape: {} bytes, no 'pong:' prefix",
reply.len()
);
}
}
/// Convenience wrapper for [`Method::PeerStatus`]. Sends an empty
/// payload, deserialises the JSON response.
pub async fn call_peer_status(conn: &Connection) -> Result<PeerStatusReply> {
let reply = rpc_call(conn, Method::PeerStatus, &[]).await?;
if reply.len() == 1 {
if let Some(code) = decode_error(reply[0]) {
bail!("peer replied with error: {}", code.describe());
}
}
serde_json::from_slice(&reply).context("decoding PeerStatusReply JSON")
}
/// Recognise a single-byte reply as one of our error codes. Returns
/// `None` for any other single-byte value (which is a valid reply,
/// just an unusually short one).
pub fn decode_error(b: u8) -> Option<ErrorCode> {
match b {
0xf0 => Some(ErrorCode::EmptyRequest),
0xf1 => Some(ErrorCode::UnknownMethod),
0xf2 => Some(ErrorCode::HandlerFailure),
0xf3 => Some(ErrorCode::NotFound),
0xf4 => Some(ErrorCode::InvalidRequest),
0xf5 => Some(ErrorCode::NotConfigured),
_ => None,
}
}
// ── Blob RPC client helpers ───────────────────────────────────────────
/// Ask the peer for a blob's size + chunk count. `Ok(None)` when the
/// peer replies [`ErrorCode::NotFound`]; other error codes surface as
/// `Err`.
pub async fn call_blob_stat(
conn: &Connection,
id: &BlobId,
) -> Result<Option<BlobStat>> {
let reply = rpc_call(conn, Method::BlobStat, id.as_bytes()).await?;
if reply.len() == 1 {
match decode_error(reply[0]) {
Some(ErrorCode::NotFound) => return Ok(None),
Some(code) => bail!("peer replied with error: {}", code.describe()),
None => {} // single-byte JSON like "1" is technically possible; fall through
}
}
let stat = serde_json::from_slice(&reply).context("decoding BlobStat JSON")?;
Ok(Some(stat))
}
/// Fetch a blob's raw bytes. `Ok(None)` when the peer replies
/// [`ErrorCode::NotFound`]. Callers that need many-GB transfers should
/// use the streaming variant (Phase 2c); this helper caps at
/// [`MAX_MESSAGE_BYTES`].
pub async fn call_blob_get(
conn: &Connection,
id: &BlobId,
) -> Result<Option<Vec<u8>>> {
let reply = rpc_call(conn, Method::BlobGet, id.as_bytes()).await?;
if reply.len() == 1 {
match decode_error(reply[0]) {
Some(ErrorCode::NotFound) => return Ok(None),
Some(code) => bail!("peer replied with error: {}", code.describe()),
None => {} // single-byte blob is legitimate content
}
}
Ok(Some(reply))
}
/// Upload a blob to the peer's store. Returns the BlobId the peer
/// assigned; must match the local hash of `bytes` (proves the peer
/// stored what we sent).
pub async fn call_blob_put(conn: &Connection, bytes: &[u8]) -> Result<BlobId> {
let reply = rpc_call(conn, Method::BlobPut, bytes).await?;
if reply.len() == 1 {
if let Some(code) = decode_error(reply[0]) {
bail!("peer replied with error: {}", code.describe());
}
}
if reply.len() != 32 {
bail!(
"expected 32-byte BlobId in reply, got {} bytes",
reply.len()
);
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&reply);
let assigned = BlobId::from_bytes(buf);
let expected = BlobId::from_bytes(blake3::hash(bytes).into());
if assigned != expected {
bail!(
"peer returned BlobId {} but content hashes to {}; corruption or protocol drift",
assigned.to_hex(),
expected.to_hex()
);
}
Ok(assigned)
}
/// Fetch a blob's manifest (chunk-list + size). `Ok(None)` on NotFound.
pub async fn call_blob_load_manifest(
conn: &Connection,
id: &BlobId,
) -> Result<Option<BlobManifest>> {
let reply = rpc_call(conn, Method::BlobLoadManifest, id.as_bytes()).await?;
if reply.len() == 1 {
match decode_error(reply[0]) {
Some(ErrorCode::NotFound) => return Ok(None),
Some(code) => bail!("peer replied with error: {}", code.describe()),
None => {}
}
}
let manifest =
serde_json::from_slice(&reply).context("decoding BlobManifest JSON")?;
Ok(Some(manifest))
}
// ── Phase 2d: chunk-level client helpers ─────────────────────────────
/// Does the peer already have this chunk? `Ok(true)` on presence,
/// `Ok(false)` on NotFound. Other errors surface as `Err`.
pub async fn call_has_chunk(
conn: &Connection,
hash: &ChunkHash,
) -> Result<bool> {
let reply = rpc_call(conn, Method::HasChunk, hash.as_bytes()).await?;
if reply.len() != 1 {
bail!(
"expected single-byte HasChunk reply, got {} bytes",
reply.len()
);
}
match reply[0] {
STREAM_STATUS_OK => Ok(true),
code => match decode_error(code) {
Some(ErrorCode::NotFound) => Ok(false),
Some(err) => bail!("peer replied with error: {}", err.describe()),
None => bail!(
"peer replied with unknown byte 0x{:02x} for HasChunk",
code
),
},
}
}
/// Upload a single chunk to the peer's store. Peer verifies bytes
/// hash to `hash` before writing; a mismatch surfaces as `Err`.
pub async fn call_put_chunk(
conn: &Connection,
hash: &ChunkHash,
bytes: &[u8],
) -> Result<()> {
let mut payload = Vec::with_capacity(32 + bytes.len());
payload.extend_from_slice(hash.as_bytes());
payload.extend_from_slice(bytes);
let reply = rpc_call(conn, Method::PutChunk, &payload).await?;
if reply.len() != 1 {
bail!(
"expected single-byte PutChunk reply, got {} bytes",
reply.len()
);
}
match reply[0] {
STREAM_STATUS_OK => Ok(()),
code => match decode_error(code) {
Some(err) => bail!("peer rejected chunk: {}", err.describe()),
None => bail!(
"peer replied with unknown byte 0x{:02x} for PutChunk",
code
),
},
}
}
/// Fetch a single chunk from the peer's store. `Ok(None)` on NotFound.
/// Verifies the returned bytes hash to `hash` locally — corruption or
/// protocol drift surfaces as `Err`.
pub async fn call_get_chunk(
conn: &Connection,
hash: &ChunkHash,
) -> Result<Option<Vec<u8>>> {
let reply = rpc_call(conn, Method::GetChunk, hash.as_bytes()).await?;
if reply.is_empty() {
bail!("empty GetChunk reply");
}
match reply[0] {
STREAM_STATUS_OK => {
let bytes = reply[1..].to_vec();
let recomputed = ChunkHash::from_bytes(blake3::hash(&bytes).into());
if recomputed != *hash {
bail!(
"GetChunk hash mismatch: requested {}, got bytes hashing to {}",
hash.to_hex(),
recomputed.to_hex()
);
}
Ok(Some(bytes))
}
code => match decode_error(code) {
Some(ErrorCode::NotFound) => Ok(None),
Some(err) => bail!("peer replied with error: {}", err.describe()),
None => {
if reply.len() == 1 {
bail!(
"peer replied with unknown byte 0x{:02x} for GetChunk",
code
);
}
// A single legitimate content byte with value 0xf3 is
// technically distinguishable from NotFound because
// GetChunk always prefixes with STREAM_STATUS_OK. The
// unreachable branch stays as belt-and-braces.
let bytes = reply[1..].to_vec();
let recomputed = ChunkHash::from_bytes(blake3::hash(&bytes).into());
if recomputed != *hash {
bail!("GetChunk hash mismatch (fallback path)");
}
Ok(Some(bytes))
}
},
}
}
/// Commit a manifest whose chunks the peer should already have.
/// Returns the list of chunk hashes the peer is still missing — an
/// empty list means the manifest was persisted; a non-empty list
/// tells the caller which chunks to upload before retrying.
pub async fn call_put_manifest(
conn: &Connection,
manifest: &BlobManifest,
) -> Result<Vec<ChunkHash>> {
let payload =
serde_json::to_vec(manifest).context("encoding BlobManifest as JSON")?;
let reply = rpc_call(conn, Method::PutManifest, &payload).await?;
if reply.len() == 1 {
if let Some(err) = decode_error(reply[0]) {
bail!("peer replied with error: {}", err.describe());
}
}
let decoded: PutManifestReply = serde_json::from_slice(&reply)
.context("decoding PutManifestReply JSON")?;
if decoded.blob_id != manifest.blob_id {
bail!(
"peer echoed blob_id {} but we sent {}",
decoded.blob_id.to_hex(),
manifest.blob_id.to_hex()
);
}
Ok(decoded.missing)
}
// ── Phase 5b: reference-store client helpers ─────────────────────────
/// Look up a 32-byte value by 32-byte key. `Ok(None)` on `NotFound`.
pub async fn call_get_ref(
conn: &Connection,
key: &RefKey,
) -> Result<Option<RefValue>> {
let reply = rpc_call(conn, Method::GetRef, key).await?;
if reply.len() == 1 {
match decode_error(reply[0]) {
Some(ErrorCode::NotFound) => return Ok(None),
Some(err) => bail!("peer replied with error: {}", err.describe()),
None => {}
}
}
if reply.len() != 32 {
bail!("expected 32-byte ref value, got {} bytes", reply.len());
}
let mut value = [0u8; 32];
value.copy_from_slice(&reply);
Ok(Some(value))
}
/// Set a 32-byte value for a 32-byte key. Overwrites any prior value.
pub async fn call_put_ref(
conn: &Connection,
key: &RefKey,
value: &RefValue,
) -> Result<()> {
let mut payload = Vec::with_capacity(64);
payload.extend_from_slice(key);
payload.extend_from_slice(value);
let reply = rpc_call(conn, Method::PutRef, &payload).await?;
if reply.len() != 1 {
bail!("expected single-byte PutRef reply, got {} bytes", reply.len());
}
match reply[0] {
STREAM_STATUS_OK => Ok(()),
code => match decode_error(code) {
Some(err) => bail!("peer rejected PutRef: {}", err.describe()),
None => bail!(
"peer replied with unknown byte 0x{:02x} for PutRef",
code
),
},
}
}
// ── Phase 5d: tag-store client helpers ───────────────────────────────
/// Publish (or overwrite) a named tag pointing at a 32-byte value.
pub async fn call_put_tag(
conn: &Connection,
key: &str,
value: &[u8; 32],
) -> Result<()> {
let payload = crate::cluster::tags::encode_record(key, value);
let reply = rpc_call(conn, Method::PutTag, &payload).await?;
if reply.len() != 1 {
bail!("expected single-byte PutTag reply, got {} bytes", reply.len());
}
match reply[0] {
STREAM_STATUS_OK => Ok(()),
code => match decode_error(code) {
Some(err) => bail!("peer rejected PutTag: {}", err.describe()),
None => bail!(
"peer replied with unknown byte 0x{:02x} for PutTag",
code
),
},
}
}
/// Look up a named tag. `Ok(None)` on `NotFound`.
pub async fn call_get_tag(
conn: &Connection,
key: &str,
) -> Result<Option<[u8; 32]>> {
if key.is_empty() {
bail!("tag key cannot be empty");
}
let reply = rpc_call(conn, Method::GetTag, key.as_bytes()).await?;
if reply.len() == 1 {
match decode_error(reply[0]) {
Some(ErrorCode::NotFound) => return Ok(None),
Some(err) => bail!("peer replied with error: {}", err.describe()),
None => {}
}
}
if reply.len() != 32 {
bail!("expected 32-byte tag value, got {} bytes", reply.len());
}
let mut value = [0u8; 32];
value.copy_from_slice(&reply);
Ok(Some(value))
}
/// Delete a named tag. `Ok(false)` when no such tag existed.
pub async fn call_delete_tag(conn: &Connection, key: &str) -> Result<bool> {
if key.is_empty() {
bail!("tag key cannot be empty");
}
let reply = rpc_call(conn, Method::DeleteTag, key.as_bytes()).await?;
if reply.len() != 1 {
bail!(
"expected single-byte DeleteTag reply, got {} bytes",
reply.len()
);
}
match reply[0] {
STREAM_STATUS_OK => Ok(true),
code => match decode_error(code) {
Some(ErrorCode::NotFound) => Ok(false),
Some(err) => bail!("peer replied with error: {}", err.describe()),
None => bail!(
"peer replied with unknown byte 0x{:02x} for DeleteTag",
code
),
},
}
}
/// List every tag stored on the peer. Sorted by key.
pub async fn call_list_tags(conn: &Connection) -> Result<Vec<TagEntry>> {
let reply = rpc_call(conn, Method::ListTags, &[]).await?;
if reply.len() == 1 {
if let Some(err) = decode_error(reply[0]) {
bail!("peer replied with error: {}", err.describe());
}
}
serde_json::from_slice(&reply).context("decoding TagEntry list JSON")
}
/// High-level partial-sync helper: replicate a local blob to a peer
/// by uploading only chunks the peer is missing.
///
/// Flow:
/// 1. Load the local manifest.
/// 2. For each chunk, ask the peer if it already has it.
/// 3. Upload the missing chunks.
/// 4. Send the manifest so the peer commits.
///
/// Returns `(uploaded_chunks, total_chunks)` — the difference is what
/// dedup saved. On a warm peer with an identical prior build both
/// numbers are equal to `manifest.chunks.len()` minus the count that
/// were already present, and only the missing-chunk bytes cross the
/// wire.
pub async fn push_blob_missing_chunks(
conn: &Connection,
local: &BlobStore,
id: &BlobId,
) -> Result<(usize, usize)> {
let manifest = local
.load_manifest(id)
.await?
.with_context(|| format!("blob {} not present locally", id.to_hex()))?;
let total = manifest.chunks.len();
let mut uploaded = 0usize;
for hash in &manifest.chunks {
if !call_has_chunk(conn, hash).await? {
let bytes = local
.read_chunk(hash)
.await?
.with_context(|| format!("chunk {} missing locally", hash.to_hex()))?;
call_put_chunk(conn, hash, &bytes).await?;
uploaded += 1;
}
}
// Commit the manifest. `missing` MUST be empty by now — if the
// peer still reports missing chunks after we uploaded them, the
// most likely cause is a store crash on their side; surface as Err.
let still_missing = call_put_manifest(conn, &manifest).await?;
if !still_missing.is_empty() {
bail!(
"peer still reports {} missing chunks after we uploaded {} — retry",
still_missing.len(),
uploaded
);
}
Ok((uploaded, total))
}