feat(01-05): surface federation sync failures to the operator (FED-02)

A failed federation sync existed only as a `debug!` line on the node, so a
peer that had not synced in days looked identical in the UI to one that
synced a minute ago. Now the failure is persisted per peer and rendered.

- `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side
  mirror of the existing `last_transport` / `last_transport_at` pair.
- `federation::record_sync_result(data_dir, did, outcome)` — records the
  message on `Err`, CLEARS both fields on `Ok` so the badge disappears when
  the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner`
  load/save convention established by plan 01-01. An unknown DID is a silent
  Ok that writes nothing, so a peer removed mid-pass is never resurrected by
  an in-flight sync's error write. Skips the save entirely when nothing
  changed, keeping the steady state read-only rather than rewriting
  nodes.json (and contending for the lock) every 90s.
- Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not
  bytes so truncation cannot split a UTF-8 sequence (T-01-18).
- The 90s auto-sync loop calls it on both arms; the existing `debug!` line
  is kept — persisting is additive, not a replacement for logs.
- `federation.list-nodes` emits both fields when set, omits them when unset.
- NodeList renders a red SYNC badge beside the transport badge on both the
  trusted-node and peer rows, message + age in the `title` so the row stays
  single-line.

Tests (written first, confirmed failing — 16 compile errors, E0425 on
`record_sync_result` and E0609 on `last_sync_error`):
- persists_error / success_clears_error / missing_did_is_noop /
  on_empty_store_is_noop / truncates_long_error
- NodeList: badge present when set, ABSENT when unset (the guard against a
  badge that always renders), and present on an observer peer row.

cargo test -p archipelago federation — 42 passed, 0 failed.
vitest NodeList.test.ts — 4 passed. npm run build — green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 11:12:07 -04:00
co-authored by Claude Opus 5
parent 262998747e
commit 454388226c
10 changed files with 358 additions and 2 deletions
+25 -1
View File
@@ -544,6 +544,15 @@ impl Server {
{
Ok(state) => {
ok += 1;
// FED-02: clear any error this peer accumulated
// while it was unreachable, so the operator's
// sync-error badge disappears on recovery
// instead of sticking around forever.
crate::federation::record_sync_result(
&data_dir, &node.did, Ok(()),
)
.await
.ok();
// Asymmetry self-heal: if this peer's exported
// trusted list doesn't include us, our original
// peer-joined never landed (e.g. it was sent
@@ -576,7 +585,22 @@ impl Server {
}
}
Err(e) => {
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)")
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)");
// FED-02: persist the failure on the peer's own
// record too. The debug! line above is kept —
// persisting is additive, not a replacement for
// logs — but on its own it left a peer that
// hadn't synced in days looking identical in the
// UI to one that synced a minute ago. The stored
// message is the error's display string, bounded
// by record_sync_result to MAX_SYNC_ERROR_CHARS.
crate::federation::record_sync_result(
&data_dir,
&node.did,
Err(format!("{e:#}")),
)
.await
.ok();
}
}
}