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 | "reset_required"22 | "sync_generation_unknown"23 | "sync_timeout"24 | "connection_lost"25 | "trust_data_corrupted"26 | "run_failed";2728293031323334353637const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([38 "no_eligible_host",39 "runner_unavailable",40 "private_relay_unset",41 "vault_id_mismatch",42 "peer_protocol_outdated",43 44 45 "reset_required",46 "sync_generation_unknown",47 48 49 "trust_data_corrupted",50 "run_failed",51]);525354555657const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([58 "relay_unreachable",59 "handshake_failed",60 "sync_timeout",61 "connection_lost",62 63 64 65 66 "vault_locked",67]);68697071727374757677787980export function isConnectionClosedError(err: unknown): boolean {81 const message = err instanceof Error ? err.message : String(err);82 return /websocket closed|connection closed/i.test(message);83}8485export function isSyncFailure(outcome: SyncOutcome): boolean {86 return FAILURES.has(outcome);87}8889export function isSyncWarning(outcome: SyncOutcome): boolean {90 return WARNINGS.has(outcome);91}9293export interface SyncOutcomeSummary {94 status?: "idle" | "syncing" | "error";95 outcome?: SyncOutcome;96 entriesSent?: number;97 entriesReceived?: number;98 detail?: string;99 100 finishedAt?: number;101}102103export function isManualSyncPending(summary: SyncOutcomeSummary): boolean {104 return summary.status === "syncing" || summary.outcome === "waiting_for_peer";105}106107108109110111112113114115export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";116117export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {118 if (s.status === "syncing") return "busy";119 if (s.outcome && isSyncFailure(s.outcome)) return "error";120 if (s.outcome && isSyncWarning(s.outcome)) return "warn";121 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {122 return "ok";123 }124 if (s.outcome === "waiting_for_peer") return "ok";125 126 127 if (pairedCount > 0 && s.outcome === undefined) return "ok";128 return "idle";129}130131const INDICATOR_LABEL: Record<SyncIndicator, string> = {132 busy: "Syncing",133 ok: "Sync healthy",134 warn: "Last sync attempt missed — will retry",135 error: "Sync problem",136 idle: "Not syncing",137};138139140export function syncIndicatorLabel(141 indicator: SyncIndicator,142 s: SyncOutcomeSummary,143): string {144 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;145}146147const COPY: Record<SyncOutcome, string> = {148 completed_changes: "Synced",149 completed_no_changes: "Up to date — nothing to send or receive",150 no_paired_devices: "No devices paired yet",151 no_eligible_host: "Paired, but no device here can accept a sync",152 waiting_for_peer: "Waiting for the paired device to connect",153 runner_unavailable: "Sync isn't running in the background — reload Peach",154 sync_disabled: "Sync is switched off",155 private_relay_unset: "Private relay chosen but no address set",156 vault_locked: "Vault was locked — unlock and sync again",157 relay_unreachable: "Couldn't reach the relay",158 handshake_failed: "Reached the relay, but the device didn't answer",159 vault_id_mismatch: "That device holds a different vault",160 peer_protocol_outdated: "Update Peach on both devices, then re-pair them",161 reset_required:162 "Sync is reset on this device — update Peach everywhere, then re-pair each device",163 sync_generation_unknown: "This device's sync generation is unreadable — sync is stopped",164 sync_timeout: "The device stopped responding mid-sync",165 connection_lost: "The connection dropped mid-sync — will retry",166 trust_data_corrupted: "Device trust data is damaged — re-pair your devices after updating",167 run_failed: "Sync failed",168};169170171172173174175176export function describeSyncOutcome(s: SyncOutcomeSummary): string {177 if (!s.outcome) return "Not synced yet";178 const base = COPY[s.outcome] ?? "Sync failed";179 if (s.outcome === "completed_changes") {180 const moved = [181 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,182 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,183 ].filter(Boolean);184 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;185 }186 return s.detail ? `${base} (${s.detail})` : base;187}