feat: Phase 4 hardening — retry/backoff, loop detection, sync status UI + tests

Phase 4.1 — Retry/backoff for failed permission pushes:
- _compute_next_retry(): capped exponential backoff with jitter (60s–1h)
- _record_push_result(): tracks push_attempts and next_retry_at per link
- replay_pending_pushes(): skips links still in backoff window, logs deferred count
- _load() migration: adds push_attempts/next_retry_at to existing records

Phase 4.2 — Loop detection (A→B→A routing cycle):
- set_peer_route_via(): returns 409 if target cell already routes peers through us
- apply_remote_permissions(): soft warning when accepting exit-relay that would cycle

Phase 4.3 — Sync staleness indicator in Cell Network UI:
- SyncBadge component: green (synced), amber (pending/failed), gray (never)
- Shows relativeTime of last sync + error message + next retry estimate
- Injected into CellPanel header alongside tunnel online/handshake status

Tests (54 new):
- TestCheckInviteConflicts: subnet overlap, domain conflict, exclude_cell (9 tests)
- TestPushInviteToRemote: success, 4xx, no endpoint, subprocess errors (7 tests)
- TestAcceptInviteNew: new cell, idempotent, healing dns/subnet changes (16 tests)
- TestAddConnectionMutualPairing: push-invite call, non-fatal failure (5 tests)
- TestPeerSyncAcceptInvite endpoint: happy path, field validation, error propagation (16 tests)
- Fixed 2 existing replay tests to clear backoff gate (simulates elapsed window)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 04:18:36 -04:00
parent 960a4ecc51
commit dc2606541c
5 changed files with 765 additions and 4 deletions
+26
View File
@@ -54,6 +54,31 @@ function StatusDot({ online }) {
: <span className="inline-block h-2 w-2 rounded-full bg-red-400 mr-1.5" title="Offline" />;
}
function SyncBadge({ conn }) {
const { last_push_status, last_push_at, last_push_error, pending_push, next_retry_at } = conn;
let color, label, tip;
if (last_push_status === 'never' || (!last_push_at && !pending_push)) {
color = 'bg-gray-300'; label = 'Sync pending';
tip = 'Permissions not yet synced to remote cell';
} else if (!pending_push && last_push_status === 'ok') {
color = 'bg-green-500'; label = `Synced${last_push_at ? ' ' + relativeTime(last_push_at) : ''}`;
tip = `Permissions last synced ${last_push_at ? relativeTime(last_push_at) : ''}`;
} else {
color = 'bg-amber-400'; label = 'Out of sync';
tip = last_push_error ? `Sync failed: ${last_push_error}` : 'Permissions pending sync';
if (next_retry_at) {
const retryIn = Math.max(0, Math.round((new Date(next_retry_at) - Date.now()) / 60000));
tip += ` — next retry in ~${retryIn}m`;
}
}
return (
<span className="inline-flex items-center gap-1 text-xs text-gray-500" title={tip}>
<span className={`inline-block h-2 w-2 rounded-full ${color}`} />
{label}
</span>
);
}
function Toast({ toasts }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2">
@@ -204,6 +229,7 @@ function CellPanel({ conn, onDisconnect, addToast }) {
{conn.last_handshake && (
<span>{relativeTime(conn.last_handshake)}</span>
)}
<SyncBadge conn={conn} />
</div>
</div>
</div>