Files
hermes-agent/apps/desktop/electron/update-api-check.ts
Teknium 6dfcf15685 fix(desktop): passive update checks use the GitHub API once a day, never git fetch
Every desktop client ran `git fetch origin main` twice every 30 minutes
(client + backend check), plus on every window focus, with no cache in the
Electron main process and `force=true` hardcoded on the backend poll so the
backend's 6h cache was bypassed too. Across the install base GitHub measured
~33.8M fetch/clone requests in 24h against the repo and asked us to poll via
the API and releases instead.

Passive checks now:
- read the branch tip with GET /repos/{slug}/commits/{branch} using the
  application/vnd.github.sha media type (40-byte body), and only when the tips
  differ call the compare endpoint for the exact behind count and the commit
  list the overlay renders. No pack negotiation; `git fetch` runs only when
  the user applies an update.
- cache the answer on disk for 24h (1h on failure), keyed on local HEAD and
  branch so applying an update or switching branch invalidates immediately.
- run from the renderer every 24h instead of 30min; window focus re-checks
  only once the daily cadence has elapsed; the poller never passes `force`.
  Menu "Check for Updates", Settings "Check now", opening the overlay and the
  post-apply re-checks still force a fresh read.

Non-GitHub origins keep a single `ls-remote` (ref advertisement only).
runGit resolves on 'close' rather than 'exit' — the early-resolving
`remote get-url` returned "" often enough to route checks down the
non-GitHub path.

update-count.ts (shallow/full-clone counting heuristics for the fetch path)
is deleted; its compare-payload parsing moved to update-api-check.ts.
2026-09-10 18:15:54 -07:00

112 lines
4.2 KiB
TypeScript

/**
* Passive update checks against the GitHub REST API instead of git.
*
* Every desktop client used to run `git fetch origin <branch>` (or `ls-remote`)
* twice every 30 minutes, plus on each window focus. Multiplied across the
* install base that is tens of millions of pack negotiations a day against one
* repo — GitHub flagged it. A passive check only needs two facts the API gives
* for free: the remote tip SHA (`GET /repos/{repo}/commits/{branch}` with the
* `application/vnd.github.sha` media type — a 40-byte body) and, when the tips
* differ, the compare endpoint's `ahead_by` + `commits[]`. `git fetch` now
* runs only when the user actually applies an update.
*
* Pure helpers here (URL builders, cache policy, payload mapping) so they are
* unit-testable without booting Electron; the bounded network call is injected.
*/
import { canonicalGitHubRemote } from './update-remote'
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000
// A failed check (offline, 403 rate-limit) is retried sooner than a good one,
// but never on every poller tick.
export const UPDATE_CHECK_FAILURE_TTL_MS = 60 * 60 * 1000
export interface CachedUpdateCheck {
fetchedAt: number
currentSha: string
branch: string
status: Record<string, unknown> & { error?: string }
}
/** `owner/repo` for any GitHub remote form; null for non-GitHub origins. */
export function githubRepoSlug(originUrl: string): string | null {
const canonical = canonicalGitHubRemote(originUrl)
const match = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical)
return match ? match[1] : null
}
export function branchTipApiUrl(slug: string, branch: string): string {
return `https://api.github.com/repos/${slug}/commits/${encodeURIComponent(branch)}`
}
export function compareApiUrl(slug: string, currentSha: string, targetSha: string): string {
return `https://api.github.com/repos/${slug}/compare/${currentSha}...${targetSha}`
}
/**
* Whether a cached result still answers a passive check. The cache is keyed on
* the local HEAD and branch: applying an update or switching branches changes
* HEAD and invalidates it immediately, so a 24h TTL never shows a stale
* "update available" after the user just updated.
*/
export function cacheIsFresh(
cached: CachedUpdateCheck | null | undefined,
{ branch, currentSha, now }: { branch: string; currentSha: string; now: number }
): boolean {
if (!cached || cached.branch !== branch || cached.currentSha !== currentSha) {
return false
}
const ttl = cached.status.error ? UPDATE_CHECK_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS
return now - cached.fetchedAt < ttl
}
export interface CompareCommit {
sha: string
summary: string
author: string
at: number
}
/**
* Map the compare payload to the shape the update overlay renders. `ahead_by`
* is how far the remote tip is ahead of local HEAD, i.e. the behind count; 0
* with differing tips means local carries commits on top of origin (not
* behind). Any shape surprise returns null so callers keep the honest
* "update available, count unknown" state instead of trusting a partial answer.
*/
export function parseCompare(payload: unknown): { behind: number; commits: CompareCommit[] } | null {
if (!payload || typeof payload !== 'object') {
return null
}
const ahead = (payload as { ahead_by?: unknown }).ahead_by
if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) {
return null
}
const raw = (payload as { commits?: unknown }).commits
const commits: CompareCommit[] = Array.isArray(raw)
? raw
.map(entry => {
const sha = typeof entry?.sha === 'string' ? entry.sha : ''
const message = typeof entry?.commit?.message === 'string' ? entry.commit.message : ''
const author = typeof entry?.commit?.author?.name === 'string' ? entry.commit.author.name : ''
const date =
typeof entry?.commit?.committer?.date === 'string' ? Date.parse(entry.commit.committer.date) : NaN
return { sha, summary: message.split('\n')[0], author, at: Number.isFinite(date) ? date : 0 }
})
.filter(commit => commit.sha)
// The overlay lists newest first; compare returns oldest first.
.reverse()
: []
return { behind: ahead, commits }
}