1234567export type SyncOutcome =8 | "completed_changes"9 | "completed_no_changes"10 | "no_paired_devices"11 | "no_eligible_host"12 | "waiting_for_peer"13 | "runner_unavailable"14 | "sync_disabled"15 | "private_relay_unset"16 | "vault_locked"17 | "relay_unreachable"18 | "handshake_failed"19 | "vault_id_mismatch"20 | "peer_protocol_outdated"21 | "sync_timeout"22 | "connection_lost"23 | "run_failed";2425262728293031323334const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([35 "no_eligible_host",36 "runner_unavailable",37 "private_relay_unset",38 "vault_id_mismatch",39 "peer_protocol_outdated",40 "run_failed",41]);424344454647const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([48 "relay_unreachable",49 "handshake_failed",50 "sync_timeout",51 "connection_lost",52 53 54 55 56 "vault_locked",57]);58596061626364656667686970export function isConnectionClosedError(err: unknown): boolean {71 const message = err instanceof Error ? err.message : String(err);72 return /websocket closed|connection closed/i.test(message);73}7475export function isSyncFailure(outcome: SyncOutcome): boolean {76 return FAILURES.has(outcome);77}7879export function isSyncWarning(outcome: SyncOutcome): boolean {80 return WARNINGS.has(outcome);81}8283export interface SyncOutcomeSummary {84 status?: "idle" | "syncing" | "error";85 outcome?: SyncOutcome;86 entriesSent?: number;87 entriesReceived?: number;88 detail?: string;89 90 finishedAt?: number;91}9293949596979899100101export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";102103export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {104 if (s.status === "syncing") return "busy";105 if (s.outcome && isSyncFailure(s.outcome)) return "error";106 if (s.outcome && isSyncWarning(s.outcome)) return "warn";107 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {108 return "ok";109 }110 if (s.outcome === "waiting_for_peer") return "ok";111 112 113 if (pairedCount > 0 && s.outcome === undefined) return "ok";114 return "idle";115}116117const INDICATOR_LABEL: Record<SyncIndicator, string> = {118 busy: "Syncing",119 ok: "Sync healthy",120 warn: "Last sync attempt missed — will retry",121 error: "Sync problem",122 idle: "Not syncing",123};124125126export function syncIndicatorLabel(127 indicator: SyncIndicator,128 s: SyncOutcomeSummary,129): string {130 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;131}132133const COPY: Record<SyncOutcome, string> = {134 completed_changes: "Synced",135 completed_no_changes: "Up to date — nothing to send or receive",136 no_paired_devices: "No devices paired yet",137 no_eligible_host: "Paired, but no device here can accept a sync",138 waiting_for_peer: "Waiting for the paired device to connect",139 runner_unavailable: "Sync isn't running in the background — reload Peach",140 sync_disabled: "Sync is switched off",141 private_relay_unset: "Private relay chosen but no address set",142 vault_locked: "Vault was locked — unlock and sync again",143 relay_unreachable: "Couldn't reach the relay",144 handshake_failed: "Reached the relay, but the device didn't answer",145 vault_id_mismatch: "That device holds a different vault",146 peer_protocol_outdated: "Your other device needs to update before it can sync",147 sync_timeout: "The device stopped responding mid-sync",148 connection_lost: "The connection dropped mid-sync — will retry",149 run_failed: "Sync failed",150};151152153154155156157158export function describeSyncOutcome(s: SyncOutcomeSummary): string {159 if (!s.outcome) return "Not synced yet";160 const base = COPY[s.outcome] ?? "Sync failed";161 if (s.outcome === "completed_changes") {162 const moved = [163 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,164 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,165 ].filter(Boolean);166 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;167 }168 return s.detail ? `${base} (${s.detail})` : base;169}