1234567export type SyncOutcome =8 | "completed_changes"9 | "completed_no_changes"10 | "no_paired_devices"11 | "no_eligible_host"12 | "runner_unavailable"13 | "sync_disabled"14 | "private_relay_unset"15 | "vault_locked"16 | "relay_unreachable"17 | "handshake_failed"18 | "vault_id_mismatch"19 | "sync_timeout"20 | "connection_lost"21 | "run_failed";2223242526272829303132const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([33 "no_eligible_host",34 "runner_unavailable",35 "private_relay_unset",36 "vault_locked",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]);51525354555657585960616263export function isConnectionClosedError(err: unknown): boolean {64 const message = err instanceof Error ? err.message : String(err);65 return /websocket closed|connection closed/i.test(message);66}6768export function isSyncFailure(outcome: SyncOutcome): boolean {69 return FAILURES.has(outcome);70}7172export function isSyncWarning(outcome: SyncOutcome): boolean {73 return WARNINGS.has(outcome);74}7576export interface SyncOutcomeSummary {77 status?: "idle" | "syncing" | "error";78 outcome?: SyncOutcome;79 entriesSent?: number;80 entriesReceived?: number;81 detail?: string;82 83 finishedAt?: number;84}85868788899091929394export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";9596export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {97 if (s.status === "syncing") return "busy";98 if (s.outcome && isSyncFailure(s.outcome)) return "error";99 if (s.outcome && isSyncWarning(s.outcome)) return "warn";100 if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {101 return "ok";102 }103 104 105 if (pairedCount > 0 && s.outcome === undefined) return "ok";106 return "idle";107}108109const INDICATOR_LABEL: Record<SyncIndicator, string> = {110 busy: "Syncing",111 ok: "Sync healthy",112 warn: "Last sync attempt missed — will retry",113 error: "Sync problem",114 idle: "Not syncing",115};116117118export function syncIndicatorLabel(119 indicator: SyncIndicator,120 s: SyncOutcomeSummary,121): string {122 return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;123}124125const COPY: Record<SyncOutcome, string> = {126 completed_changes: "Synced",127 completed_no_changes: "Up to date — nothing to send or receive",128 no_paired_devices: "No devices paired yet",129 no_eligible_host: "Paired, but no device here can accept a sync",130 runner_unavailable: "Sync isn't running in the background — reload Peach",131 sync_disabled: "Sync is switched off",132 private_relay_unset: "Private relay chosen but no address set",133 vault_locked: "Vault was locked — unlock and sync again",134 relay_unreachable: "Couldn't reach the relay",135 handshake_failed: "Reached the relay, but the device didn't answer",136 vault_id_mismatch: "That device holds a different vault",137 sync_timeout: "The device stopped responding mid-sync",138 connection_lost: "The connection dropped mid-sync — will retry",139 run_failed: "Sync failed",140};141142143144145146147148export function describeSyncOutcome(s: SyncOutcomeSummary): string {149 if (!s.outcome) return "Not synced yet";150 const base = COPY[s.outcome] ?? "Sync failed";151 if (s.outcome === "completed_changes") {152 const moved = [153 (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,154 (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,155 ].filter(Boolean);156 return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;157 }158 return s.detail ? `${base} (${s.detail})` : base;159}