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 | "sync_timeout"21 | "connection_lost"22 | "run_failed";2324252627282930313233const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([34 "no_eligible_host",35 "runner_unavailable",36 "private_relay_unset",37 "vault_id_mismatch",38 "run_failed",39]);404142434445const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([46 "relay_unreachable",47 "handshake_failed",48 "sync_timeout",49 "connection_lost",50 51 52 53 54 "vault_locked",55]);56575859606162636465666768export function isConnectionClosedError(err: unknown): boolean {69 const message = err instanceof Error ? err.message : String(err);70 return /websocket closed|connection closed/i.test(message);71}7273export function isSyncFailure(outcome: SyncOutcome): boolean {74 return FAILURES.has(outcome);75}7677export function isSyncWarning(outcome: SyncOutcome): boolean {78 return WARNINGS.has(outcome);79}8081export interface SyncOutcomeSummary {82 status?: "idle" | "syncing" | "error";83 outcome?: SyncOutcome;84 entriesSent?: number;85 entriesReceived?: number;86 detail?: string;87 88 finishedAt?: number;89}90919293949596979899export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";100101export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {102 if (s.status === "syncing") return "busy";103 if (s.outcome && isSyncFailure(s.outcome)) return "error";104 if (s.outcome && isSyncWarning(s.outcome)) return "warn";105 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {106 return "ok";107 }108 if (s.outcome === "waiting_for_peer") return "ok";109 110 111 if (pairedCount > 0 && s.outcome === undefined) return "ok";112 return "idle";113}114115const INDICATOR_LABEL: Record<SyncIndicator, string> = {116 busy: "Syncing",117 ok: "Sync healthy",118 warn: "Last sync attempt missed — will retry",119 error: "Sync problem",120 idle: "Not syncing",121};122123124export function syncIndicatorLabel(125 indicator: SyncIndicator,126 s: SyncOutcomeSummary,127): string {128 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;129}130131const COPY: Record<SyncOutcome, string> = {132 completed_changes: "Synced",133 completed_no_changes: "Up to date — nothing to send or receive",134 no_paired_devices: "No devices paired yet",135 no_eligible_host: "Paired, but no device here can accept a sync",136 waiting_for_peer: "Waiting for the paired device to connect",137 runner_unavailable: "Sync isn't running in the background — reload Peach",138 sync_disabled: "Sync is switched off",139 private_relay_unset: "Private relay chosen but no address set",140 vault_locked: "Vault was locked — unlock and sync again",141 relay_unreachable: "Couldn't reach the relay",142 handshake_failed: "Reached the relay, but the device didn't answer",143 vault_id_mismatch: "That device holds a different vault",144 sync_timeout: "The device stopped responding mid-sync",145 connection_lost: "The connection dropped mid-sync — will retry",146 run_failed: "Sync failed",147};148149150151152153154155export function describeSyncOutcome(s: SyncOutcomeSummary): string {156 if (!s.outcome) return "Not synced yet";157 const base = COPY[s.outcome] ?? "Sync failed";158 if (s.outcome === "completed_changes") {159 const moved = [160 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,161 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,162 ].filter(Boolean);163 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;164 }165 return s.detail ? `${base} (${s.detail})` : base;166}