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.
This commit is contained in:
@@ -201,7 +201,6 @@ import {
|
||||
import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { registerGitIpc } from './git-ipc'
|
||||
import { clearStaleGitLocks } from './gitlock'
|
||||
import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding'
|
||||
import { readAndConsumeHandoffResult } from './handoff-result'
|
||||
import {
|
||||
@@ -385,13 +384,7 @@ import {
|
||||
windowOpacityFor,
|
||||
windowOpacityOptions
|
||||
} from './translucency'
|
||||
import {
|
||||
compareApiUrl,
|
||||
parseCompareBehindCount,
|
||||
resolveBehindCount,
|
||||
resolveCommitLogSelection,
|
||||
shouldCountCommits
|
||||
} from './update-count'
|
||||
import { branchTipApiUrl, cacheIsFresh, compareApiUrl, githubRepoSlug, parseCompare } from './update-api-check'
|
||||
import { waitForUpdateClearance } from './update-gate'
|
||||
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
|
||||
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
@@ -856,6 +849,7 @@ const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'conne
|
||||
const DESKTOP_CONNECTIONS_REGISTRY_PATH = path.join(app.getPath('userData'), 'connections.json')
|
||||
const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json')
|
||||
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
||||
const DESKTOP_UPDATE_CHECK_CACHE_PATH = path.join(app.getPath('userData'), 'update-check-cache.json')
|
||||
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
|
||||
const DESKTOP_BACKEND_OWNERSHIP_PATH = path.join(app.getPath('userData'), 'backend-ownership.json')
|
||||
const DESKTOP_MANAGED_SSH_RECOVERY_PATH = path.join(app.getPath('userData'), 'managed-ssh-update-recovery.json')
|
||||
@@ -3093,7 +3087,10 @@ function runGit(args, options: any = {}): Promise<{ code: number; stdout: string
|
||||
options.onLine?.('stderr', text)
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', code => resolve({ code, stdout, stderr }))
|
||||
// 'close', not 'exit': exit can fire before the stdio pipes drain, and a
|
||||
// resolved-early `remote get-url` came back as "" often enough to route
|
||||
// passive checks down the wrong remote path.
|
||||
child.once('close', code => resolve({ code, stdout, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3143,7 +3140,15 @@ async function resolveHealedBranch(updateRoot, branch) {
|
||||
return 'main'
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
// Passive checks never touch git's network side. Every client used to `git
|
||||
// fetch` twice per half hour; across the install base that was tens of
|
||||
// millions of pack negotiations a day against one repo (GitHub flagged it).
|
||||
// The REST API answers the same question in one 40-byte response, so the
|
||||
// check is API-first with a 24h on-disk cache keyed on local HEAD (applying an
|
||||
// update changes HEAD, which busts the cache immediately). `git fetch` runs only
|
||||
// inside applyUpdates. `force` (menu item, Settings "Check now") skips the
|
||||
// cache; the renderer's background poller never passes it.
|
||||
async function checkUpdates({ force = false }: { force?: boolean } = {}) {
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
let { branch } = readDesktopUpdateConfig()
|
||||
const gitDir = path.join(updateRoot, '.git')
|
||||
@@ -3158,214 +3163,175 @@ async function checkUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
branch = await resolveHealedBranch(updateRoot, branch)
|
||||
const originUrl = await getOriginUrl(updateRoot)
|
||||
|
||||
if (isOfficialSshRemote(originUrl)) {
|
||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||
|
||||
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
|
||||
git(['rev-parse', 'HEAD']),
|
||||
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
|
||||
git(['status', '--porcelain']),
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
])
|
||||
|
||||
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
||||
|
||||
if (target.code !== 0 || !targetSha) {
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
error: 'fetch-failed',
|
||||
message: firstLine(target.stderr) || 'git ls-remote failed.',
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// Passive SSH-official checks only know tip SHAs (ls-remote) — never
|
||||
// fabricate a "1 commit behind". Recover the exact count via the GitHub
|
||||
// compare API when possible; otherwise behind stays null ("update
|
||||
// available, count unknown") and updateAvailable carries the signal.
|
||||
// ahead_by === 0 with differing tips means the remote tip is reachable
|
||||
// from our HEAD — a local carried commit sitting AHEAD, not behind:
|
||||
// flagging that as an update nudges the user into wiping their work.
|
||||
const tipsEqual = Boolean(currentSha && currentSha === targetSha)
|
||||
|
||||
const sshBehind = tipsEqual
|
||||
? 0
|
||||
: await fetchCompareBehindCount({ currentSha, originUrl: OFFICIAL_REPO_HTTPS_URL, targetSha })
|
||||
|
||||
const upToDate = tipsEqual || sshBehind === 0
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
currentBranch,
|
||||
behind: upToDate ? 0 : sshBehind,
|
||||
updateAvailable: !upToDate,
|
||||
currentSha,
|
||||
targetSha,
|
||||
commits: [],
|
||||
dirty: dirtyStr.length > 0,
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal abandoned git lock files before fetching. A stale
|
||||
// .git/shallow.lock from a crashed/interrupted fetch otherwise fails every
|
||||
// later fetch ("Unable to create '.git/shallow.lock': File exists") and this
|
||||
// check reports 'fetch-failed' forever — git never removes these itself.
|
||||
await clearStaleGitLocks(updateRoot)
|
||||
|
||||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||||
|
||||
if (fetched.code !== 0) {
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
error: 'fetch-failed',
|
||||
message: firstLine(fetched.stderr) || 'git fetch failed.',
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||
|
||||
const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr] = await Promise.all([
|
||||
const [currentSha, dirtyStr, currentBranch, originUrl] = await Promise.all([
|
||||
git(['rev-parse', 'HEAD']),
|
||||
git(['rev-parse', `origin/${branch}`]),
|
||||
git(['status', '--porcelain']),
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
||||
git(['rev-parse', '--is-shallow-repository'])
|
||||
getOriginUrl(updateRoot)
|
||||
])
|
||||
|
||||
const isShallow = shallowStr === 'true'
|
||||
const cached = readUpdateCheckCache()
|
||||
const now = Date.now()
|
||||
|
||||
// A shallow graph cannot provide a trustworthy exact count, even when it has
|
||||
// a visible merge-base. Skip the ancestry walk and use the SHA fallback.
|
||||
const countStr = shouldCountCommits({ isShallow }) ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) : ''
|
||||
|
||||
// A positive directional ancestry result remains trustworthy in a shallow
|
||||
// graph and prevents a local commit on top of origin from looking outdated.
|
||||
const targetIsAncestorOfHead =
|
||||
isShallow &&
|
||||
currentSha !== targetSha &&
|
||||
(await runGit(['merge-base', '--is-ancestor', `origin/${branch}`, 'HEAD'], { cwd: updateRoot })).code === 0
|
||||
|
||||
let behind = resolveBehindCount({
|
||||
countStr,
|
||||
currentSha,
|
||||
targetSha,
|
||||
isShallow,
|
||||
targetIsAncestorOfHead
|
||||
})
|
||||
|
||||
// Recover the exact count a shallow clone can't compute: the GitHub compare
|
||||
// API knows the full graph regardless of local clone depth. Best-effort —
|
||||
// offline, rate-limited, or non-GitHub origins keep the honest null
|
||||
// ("update available", no fabricated number).
|
||||
if (behind === null) {
|
||||
behind = await fetchCompareBehindCount({ currentSha, originUrl, targetSha })
|
||||
if (!force && cacheIsFresh(cached, { branch, currentSha, now })) {
|
||||
return { ...cached.status, dirty: dirtyStr.length > 0, currentBranch }
|
||||
}
|
||||
|
||||
// behind === null means "update available, exact count unknown" (shallow
|
||||
// clone): still list what origin offers — resolveCommitLogSelection keeps
|
||||
// the shallow log to the fetched tip so the range walk can't enumerate the
|
||||
// contaminated ancestry — so "See what's new" stays useful and honest.
|
||||
const commits = behind !== 0 ? await readCommitLog(updateRoot, branch, isShallow) : []
|
||||
branch = await resolveHealedBranch(updateRoot, branch)
|
||||
const slug = githubRepoSlug(originUrl)
|
||||
|
||||
return {
|
||||
const status = slug
|
||||
? await checkUpdatesViaApi({ slug, branch, currentSha })
|
||||
: await checkUpdatesViaLsRemote({ updateRoot, branch, currentSha })
|
||||
|
||||
const result = {
|
||||
supported: true,
|
||||
branch,
|
||||
currentBranch,
|
||||
behind,
|
||||
updateAvailable: behind === null || behind > 0,
|
||||
currentSha,
|
||||
targetSha,
|
||||
commits,
|
||||
dirty: dirtyStr.length > 0,
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
fetchedAt: now,
|
||||
...status
|
||||
}
|
||||
|
||||
writeUpdateCheckCache({ fetchedAt: now, currentSha, branch, status: result })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Best-effort exact behind-count for graphs the local clone can't measure.
|
||||
// Delegates URL building + response parsing to update-count.ts (pure, unit
|
||||
// tested); this wrapper only does the bounded network call. Any failure —
|
||||
// offline, 4xx/5xx, rate limit, shape surprise — returns null so callers keep
|
||||
// the honest "update available, count unknown" state.
|
||||
async function fetchCompareBehindCount({ currentSha, originUrl, targetSha }) {
|
||||
const url = compareApiUrl({ currentSha, originUrl, targetSha })
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
function readUpdateCheckCache() {
|
||||
try {
|
||||
const payload = await new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
// GitHub requires a UA on api.github.com; requests without one 403.
|
||||
'User-Agent': 'hermes-desktop-update-check'
|
||||
},
|
||||
timeout: 10_000
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`compare API ${res.statusCode}`))
|
||||
const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CHECK_CACHE_PATH, 'utf8'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('compare API timeout')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
|
||||
return parseCompareBehindCount(payload)
|
||||
return parsed && typeof parsed === 'object' && parsed.status ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readCommitLog(cwd, branch, isShallow) {
|
||||
const SEP = '\x1f'
|
||||
const REC = '\x1e'
|
||||
const { limit, revision } = resolveCommitLogSelection({ branch, isShallow })
|
||||
function writeUpdateCheckCache(entry) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CHECK_CACHE_PATH), { recursive: true })
|
||||
writeFileAtomic(DESKTOP_UPDATE_CHECK_CACHE_PATH, JSON.stringify(entry))
|
||||
} catch (error) {
|
||||
rememberLog(`[updates] could not persist check cache: ${error?.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout } = await runGit(
|
||||
['log', revision, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', String(limit)],
|
||||
{ cwd }
|
||||
)
|
||||
// GitHub origins (official repo AND forks): tip SHA via the commits endpoint,
|
||||
// then the compare endpoint only when the tips differ — it yields the exact
|
||||
// behind count plus the commit list the overlay renders, replacing both
|
||||
// `rev-list --count` and `git log HEAD..origin/<branch>`.
|
||||
async function checkUpdatesViaApi({ slug, branch, currentSha }) {
|
||||
let targetSha
|
||||
|
||||
return stdout
|
||||
.split(REC)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [sha, summary, author, at] = line.split(SEP)
|
||||
try {
|
||||
targetSha = String(await fetchGitHubApi(branchTipApiUrl(slug, branch), 'application/vnd.github.sha')).trim()
|
||||
} catch (error) {
|
||||
return { error: 'fetch-failed', message: `GitHub API: ${error?.message || error}` }
|
||||
}
|
||||
|
||||
return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 }
|
||||
})
|
||||
if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
|
||||
return { error: 'fetch-failed', message: 'GitHub API returned no tip SHA.' }
|
||||
}
|
||||
|
||||
if (targetSha === currentSha) {
|
||||
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
|
||||
}
|
||||
|
||||
// Compare failure (rate-limited, local-only HEAD 404) keeps the honest
|
||||
// "update available, count unknown" — never a fabricated number.
|
||||
const compared = await fetchGitHubApi(compareApiUrl(slug, currentSha, targetSha))
|
||||
.then(parseCompare)
|
||||
.catch(() => null)
|
||||
|
||||
// ahead_by === 0 with differing tips: the remote tip is reachable from our
|
||||
// HEAD — a local commit sitting AHEAD, not behind. Flagging that as an update
|
||||
// nudges the user into wiping their work.
|
||||
if (compared?.behind === 0) {
|
||||
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
behind: compared ? compared.behind : null,
|
||||
updateAvailable: true,
|
||||
targetSha,
|
||||
commits: compared?.commits ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// Non-GitHub origins: one ls-remote for the tip SHA (still no pack transfer),
|
||||
// counting via the local graph only when the tip is already known locally.
|
||||
async function checkUpdatesViaLsRemote({ updateRoot, branch, currentSha }) {
|
||||
const target = await runGit(['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: updateRoot })
|
||||
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
||||
|
||||
if (target.code !== 0 || !targetSha) {
|
||||
return { error: 'fetch-failed', message: firstLine(target.stderr) || 'git ls-remote failed.' }
|
||||
}
|
||||
|
||||
if (targetSha === currentSha) {
|
||||
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
|
||||
}
|
||||
|
||||
const known = (await runGit(['cat-file', '-e', `${targetSha}^{commit}`], { cwd: updateRoot })).code === 0
|
||||
|
||||
const isAncestor =
|
||||
known && (await runGit(['merge-base', '--is-ancestor', targetSha, 'HEAD'], { cwd: updateRoot })).code === 0
|
||||
|
||||
if (isAncestor) {
|
||||
return { behind: 0, updateAvailable: false, targetSha, commits: [] }
|
||||
}
|
||||
|
||||
return { behind: null, updateAvailable: true, targetSha, commits: [] }
|
||||
}
|
||||
|
||||
function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: accept,
|
||||
// GitHub requires a UA on api.github.com; requests without one 403.
|
||||
'User-Agent': 'hermes-desktop-update-check'
|
||||
},
|
||||
timeout: 10_000
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8')
|
||||
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (accept === 'application/vnd.github.sha') {
|
||||
resolve(body)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
let updateInFlight = false
|
||||
@@ -17557,8 +17523,8 @@ const terminalIpc = registerTerminalIpc({
|
||||
|
||||
const disposeTerminalSession = terminalIpc.disposeTerminalSession
|
||||
|
||||
ipcMain.handle('hermes:updates:check', async () =>
|
||||
checkUpdates().catch(error => ({
|
||||
ipcMain.handle('hermes:updates:check', async (_event, opts) =>
|
||||
checkUpdates({ force: Boolean(opts?.force) }).catch(error => ({
|
||||
supported: true,
|
||||
branch: readDesktopUpdateConfig().branch,
|
||||
error: 'check-failed',
|
||||
|
||||
@@ -504,7 +504,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
|
||||
},
|
||||
updates: {
|
||||
check: () => ipcRenderer.invoke('hermes:updates:check'),
|
||||
check: opts => ipcRenderer.invoke('hermes:updates:check', opts),
|
||||
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
||||
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
|
||||
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
|
||||
|
||||
81
apps/desktop/electron/update-api-check.test.ts
Normal file
81
apps/desktop/electron/update-api-check.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tests for electron/update-api-check.ts — the API-first passive update check.
|
||||
*
|
||||
* Why this exists: every desktop client used to `git fetch` twice every 30
|
||||
* minutes. GitHub measured tens of millions of fetch/clone requests per day
|
||||
* from the install base and asked us to poll via the API instead. These pin
|
||||
* the two load-bearing contracts: the cache answers passive checks for a full
|
||||
* day but invalidates the moment HEAD moves, and the compare payload maps to
|
||||
* an honest behind count (never a fabricated one).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
branchTipApiUrl,
|
||||
cacheIsFresh,
|
||||
githubRepoSlug,
|
||||
parseCompare,
|
||||
UPDATE_CHECK_FAILURE_TTL_MS,
|
||||
UPDATE_CHECK_TTL_MS
|
||||
} from './update-api-check'
|
||||
|
||||
const SHA_A = 'a'.repeat(40)
|
||||
const SHA_B = 'b'.repeat(40)
|
||||
const HOUR = 60 * 60 * 1000
|
||||
|
||||
test('cache serves a passive check for 24h, but not once HEAD or the branch changes', () => {
|
||||
const cached = { fetchedAt: 0, currentSha: SHA_A, branch: 'main', status: { behind: 0 } }
|
||||
|
||||
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS - 1 }), true)
|
||||
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS }), false)
|
||||
// Applying an update moves HEAD: a stale "update available" must never survive it.
|
||||
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_B, now: 1 }), false)
|
||||
assert.equal(cacheIsFresh(cached, { branch: 'bb/gui', currentSha: SHA_A, now: 1 }), false)
|
||||
|
||||
// Failures retry sooner than successes, but still not on every tick.
|
||||
const failed = { ...cached, status: { error: 'fetch-failed' } }
|
||||
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_FAILURE_TTL_MS - 1 }), true)
|
||||
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: 2 * HOUR }), false)
|
||||
})
|
||||
|
||||
test('compare payload maps to the behind count and a newest-first commit list; malformed = null', () => {
|
||||
const payload = {
|
||||
ahead_by: 2,
|
||||
status: 'ahead',
|
||||
commits: [
|
||||
{
|
||||
sha: SHA_A,
|
||||
commit: { message: 'fix: older\n\nbody', author: { name: 'A' }, committer: { date: '2026-09-10T00:00:00Z' } }
|
||||
},
|
||||
{
|
||||
sha: SHA_B,
|
||||
commit: { message: 'feat: newer', author: { name: 'B' }, committer: { date: '2026-09-10T01:00:00Z' } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const parsed = parseCompare(payload)
|
||||
assert.equal(parsed?.behind, 2)
|
||||
assert.deepEqual(
|
||||
parsed?.commits.map(c => [c.sha, c.summary, c.author]),
|
||||
[
|
||||
[SHA_B, 'feat: newer', 'B'],
|
||||
[SHA_A, 'fix: older', 'A']
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(parseCompare({ ahead_by: -1 }), null)
|
||||
assert.equal(parseCompare({ status: 'ahead' }), null)
|
||||
assert.equal(parseCompare('nope'), null)
|
||||
|
||||
// Forks and SSH forms hit the API for their own repo; non-GitHub origins don't.
|
||||
assert.equal(githubRepoSlug('git@github.com:Someone/hermes-agent.git'), 'someone/hermes-agent')
|
||||
assert.equal(githubRepoSlug('https://gitlab.example/x/y.git'), null)
|
||||
assert.equal(
|
||||
branchTipApiUrl('nousresearch/hermes-agent', 'bb/gui'),
|
||||
'https://api.github.com/repos/nousresearch/hermes-agent/commits/bb%2Fgui'
|
||||
)
|
||||
})
|
||||
111
apps/desktop/electron/update-api-check.ts
Normal file
111
apps/desktop/electron/update-api-check.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
compareApiUrl,
|
||||
parseCompareBehindCount,
|
||||
resolveBehindCount,
|
||||
resolveCommitLogSelection,
|
||||
shouldCountCommits
|
||||
} from './update-count'
|
||||
|
||||
function createTempGitRepo() {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-count-'))
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }).trim()
|
||||
|
||||
try {
|
||||
git('init', '--quiet')
|
||||
git('config', 'commit.gpgSign', 'false')
|
||||
git('config', 'core.hooksPath', '.git/no-hooks')
|
||||
git('config', 'user.name', 'Hermes Test')
|
||||
git('config', 'user.email', 'hermes@example.invalid')
|
||||
|
||||
return { cwd, git }
|
||||
} catch (error) {
|
||||
fs.rmSync(cwd, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
// rev-list count (e.g. 12104) — #51922. Later the branch returned the sentinel
|
||||
// `1`, which the UI rendered as a literal "1 change included" even when the
|
||||
// true count was far higher (e.g. 90, or the real-world 61 in #84591). An
|
||||
// update IS available here, but its exact size is unknown — the only honest
|
||||
// value is `null`.
|
||||
test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '12104',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '12104',
|
||||
currentSha: 'abc',
|
||||
targetSha: 'abc',
|
||||
isShallow: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('shallow local-ahead checkout reports up-to-date when origin is a known ancestor', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'local-child',
|
||||
targetSha: 'origin-parent',
|
||||
isShallow: true,
|
||||
targetIsAncestorOfHead: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('shallow Git graph proves the remote tip is an ancestor of a local commit', () => {
|
||||
const { cwd, git } = createTempGitRepo()
|
||||
|
||||
try {
|
||||
git('commit', '--allow-empty', '-m', 'origin tip')
|
||||
|
||||
const targetSha = git('rev-parse', 'HEAD')
|
||||
|
||||
git('update-ref', 'refs/remotes/origin/main', targetSha)
|
||||
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${targetSha}\n`)
|
||||
git('commit', '--allow-empty', '-m', 'local child')
|
||||
|
||||
const currentSha = git('rev-parse', 'HEAD')
|
||||
|
||||
git('merge-base', '--is-ancestor', 'origin/main', 'HEAD')
|
||||
assert.notEqual(currentSha, targetSha)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha,
|
||||
targetSha,
|
||||
isShallow: true,
|
||||
targetIsAncestorOfHead: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test('shallow checkout with a merge-base does not trust an inflated rev-list count', () => {
|
||||
const { cwd, git } = createTempGitRepo()
|
||||
|
||||
try {
|
||||
git('commit', '--allow-empty', '-m', 'root')
|
||||
git('commit', '--allow-empty', '-m', 'ancestor')
|
||||
|
||||
const redundantParent = git('rev-parse', 'HEAD')
|
||||
|
||||
git('commit', '--allow-empty', '-m', 'installed head')
|
||||
|
||||
const currentSha = git('rev-parse', 'HEAD')
|
||||
const tree = git('rev-parse', 'HEAD^{tree}')
|
||||
|
||||
const targetSha = execFileSync('git', ['commit-tree', tree, '-p', currentSha, '-p', redundantParent], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
input: 'remote merge\n',
|
||||
timeout: 10_000
|
||||
}).trim()
|
||||
|
||||
git('update-ref', 'refs/remotes/origin/main', targetSha)
|
||||
|
||||
const completeCount = git('rev-list', 'HEAD..origin/main', '--count')
|
||||
|
||||
assert.equal(completeCount, '1')
|
||||
|
||||
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${currentSha}\n`)
|
||||
|
||||
assert.equal(git('rev-parse', '--is-shallow-repository'), 'true')
|
||||
assert.equal(git('merge-base', 'HEAD', 'origin/main'), currentSha)
|
||||
|
||||
const shallowCount = git('rev-list', 'HEAD..origin/main', '--count')
|
||||
|
||||
assert.ok(Number.parseInt(shallowCount, 10) > Number.parseInt(completeCount, 10))
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: shallowCount,
|
||||
currentSha,
|
||||
targetSha,
|
||||
isShallow: true
|
||||
}),
|
||||
null
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test('shallow checkout with a merge-base still uses presence-only status', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '3',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '7',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: false
|
||||
}),
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
test('up-to-date full clone reports 0', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '0',
|
||||
currentSha: 'x',
|
||||
targetSha: 'x',
|
||||
isShallow: false
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: false
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
|
||||
// Every shallow graph is incomplete, so a visible merge-base is not enough to
|
||||
// prove that the count is exact.
|
||||
test('shallow checkouts skip the rev-list count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: true }), false)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clones run the rev-list count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: false }), true)
|
||||
})
|
||||
|
||||
test('shallow commit logs select only the fetched remote tip', () => {
|
||||
assert.deepEqual(resolveCommitLogSelection({ branch: 'main', isShallow: true }), {
|
||||
limit: 1,
|
||||
revision: 'origin/main'
|
||||
})
|
||||
})
|
||||
|
||||
test('full-clone commit logs keep the complete behind range', () => {
|
||||
assert.deepEqual(resolveCommitLogSelection({ branch: 'release', isShallow: false }), {
|
||||
limit: 40,
|
||||
revision: 'HEAD..origin/release'
|
||||
})
|
||||
})
|
||||
|
||||
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
|
||||
// it and must fall through to the SHA compare (mirrors the live call site).
|
||||
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true
|
||||
}),
|
||||
null
|
||||
)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'same',
|
||||
targetSha: 'same',
|
||||
isShallow: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
// --- compare-API recovery: the accuracy half of the class fix (#84591) ---
|
||||
|
||||
const SHA_A = 'a'.repeat(40)
|
||||
const SHA_B = 'b'.repeat(40)
|
||||
|
||||
test('compareApiUrl builds the GitHub compare URL for HTTPS origins', () => {
|
||||
assert.equal(
|
||||
compareApiUrl({
|
||||
currentSha: SHA_A,
|
||||
originUrl: 'https://github.com/NousResearch/hermes-agent.git',
|
||||
targetSha: SHA_B
|
||||
}),
|
||||
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
|
||||
)
|
||||
})
|
||||
|
||||
test('compareApiUrl handles SSH origin forms', () => {
|
||||
for (const originUrl of [
|
||||
'git@github.com:NousResearch/hermes-agent.git',
|
||||
'ssh://git@github.com/NousResearch/hermes-agent.git',
|
||||
'git@github.com:NousResearch/hermes-agent'
|
||||
]) {
|
||||
assert.equal(
|
||||
compareApiUrl({ currentSha: SHA_A, originUrl, targetSha: SHA_B }),
|
||||
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('compareApiUrl refuses non-GitHub remotes and partial SHAs', () => {
|
||||
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: 'https://gitlab.com/x/y.git', targetSha: SHA_B }), null)
|
||||
assert.equal(compareApiUrl({ currentSha: 'abc123', originUrl: 'https://github.com/x/y.git', targetSha: SHA_B }), null)
|
||||
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: '', targetSha: SHA_B }), null)
|
||||
})
|
||||
|
||||
test('parseCompareBehindCount returns ahead_by (the behind count)', () => {
|
||||
assert.equal(parseCompareBehindCount({ ahead_by: 61, status: 'ahead' }), 61)
|
||||
assert.equal(parseCompareBehindCount({ ahead_by: 0, status: 'behind' }), 0)
|
||||
})
|
||||
|
||||
test('parseCompareBehindCount rejects malformed payloads', () => {
|
||||
assert.equal(parseCompareBehindCount(null), null)
|
||||
assert.equal(parseCompareBehindCount({}), null)
|
||||
assert.equal(parseCompareBehindCount({ ahead_by: -2 }), null)
|
||||
assert.equal(parseCompareBehindCount({ ahead_by: '61' }), null)
|
||||
assert.equal(parseCompareBehindCount({ ahead_by: 1.5 }), null)
|
||||
assert.equal(parseCompareBehindCount([]), null)
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
|
||||
// number worth computing. Installer checkouts are shallow (`--depth 1`), so
|
||||
// their visible graph is incomplete even when `merge-base` happens to find a
|
||||
// common commit. A merge can expose ancestry that the local shallow boundary
|
||||
// hides from HEAD, inflating the count with old commits. Exact counts are only
|
||||
// trustworthy in full clones; shallow checkouts use presence-only status plus
|
||||
// any positively proven local-ahead ancestry.
|
||||
function shouldCountCommits({ isShallow }) {
|
||||
return !isShallow
|
||||
}
|
||||
|
||||
// Resolve how many commits the local checkout is behind origin for the desktop
|
||||
// update indicator. Shallow checkouts use SHA equality plus any positively
|
||||
// proven local-ahead ancestry; exact counts remain exclusive to full clones.
|
||||
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, targetIsAncestorOfHead = false }) {
|
||||
if (!shouldCountCommits({ isShallow })) {
|
||||
if (currentSha && targetSha && (currentSha === targetSha || targetIsAncestorOfHead)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// An update IS available, but its size is unknowable without a merge-base.
|
||||
// Return null — never a numeric sentinel: the UI used to render the old
|
||||
// `1` as a literal "1 change included" even when the true distance was
|
||||
// far larger. null lets every surface say "update available" honestly.
|
||||
return null
|
||||
}
|
||||
|
||||
return Number.parseInt(countStr, 10) || 0
|
||||
}
|
||||
|
||||
// Shallow history can also contaminate the changelog range. Trust the fetched
|
||||
// remote tip itself, but do not walk its ancestry. Full clones retain the
|
||||
// detailed range used by the existing update overlay.
|
||||
function resolveCommitLogSelection({ branch, isShallow }) {
|
||||
const remote = `origin/${branch}`
|
||||
|
||||
return isShallow ? { limit: 1, revision: remote } : { limit: 40, revision: `HEAD..${remote}` }
|
||||
}
|
||||
|
||||
// When the local graph can't count (behind === null), the GitHub compare API
|
||||
// still can: `GET /repos/<owner>/<repo>/compare/<current>...<target>` returns
|
||||
// `ahead_by` — how many commits the remote tip is ahead of the local HEAD,
|
||||
// i.e. exactly the behind count the shallow clone lost. Unauthenticated, no
|
||||
// clone depth required. Pure URL builder + response parser here; the network
|
||||
// call lives with the caller.
|
||||
function compareApiUrl({ currentSha, originUrl, targetSha }) {
|
||||
const sha = /^[0-9a-f]{40}$/i
|
||||
|
||||
if (!sha.test(currentSha || '') || !sha.test(targetSha || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only GitHub remotes have a compare API. Reuse the canonical form the
|
||||
// official-remote check produces: `github.com/<owner>/<repo>`.
|
||||
const canonical = canonicalRemoteForCompare(originUrl)
|
||||
|
||||
if (!canonical) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `https://api.github.com/repos/${canonical}/compare/${currentSha}...${targetSha}`
|
||||
}
|
||||
|
||||
function canonicalRemoteForCompare(originUrl) {
|
||||
const value = String(originUrl || '').trim()
|
||||
|
||||
const match =
|
||||
/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) ||
|
||||
/^(?:ssh:\/\/git@|https:\/\/|http:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value)
|
||||
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
// `ahead_by` counts target commits not reachable from current — the behind
|
||||
// count. `status` is "ahead" / "behind" / "diverged" / "identical" relative to
|
||||
// current...target; any shape surprise returns null so the caller keeps the
|
||||
// honest "update available" fallback instead of trusting a partial answer.
|
||||
function parseCompareBehindCount(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const ahead = payload.ahead_by
|
||||
|
||||
if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ahead
|
||||
}
|
||||
|
||||
export { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits }
|
||||
@@ -72,7 +72,7 @@ export function AboutSettings() {
|
||||
|
||||
const handleCheck = async () => {
|
||||
setJustChecked(false)
|
||||
const next = await checkUpdates()
|
||||
const next = await checkUpdates({ force: true })
|
||||
setJustChecked(Boolean(next))
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export function UpdatesOverlay() {
|
||||
commits={status?.commits ?? []}
|
||||
onInstall={handleInstall}
|
||||
onLater={() => handleClose(false)}
|
||||
onRetryCheck={() => void check()}
|
||||
onRetryCheck={() => void check({ force: true })}
|
||||
status={status}
|
||||
target={target}
|
||||
updateAvailable={updateAvailable}
|
||||
|
||||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
@@ -526,7 +526,7 @@ declare global {
|
||||
relaunchApp?: () => Promise<void>
|
||||
getRemoteDisplayReason?: () => Promise<string | null>
|
||||
updates: {
|
||||
check: () => Promise<DesktopUpdateStatus>
|
||||
check: (opts?: { force?: boolean }) => Promise<DesktopUpdateStatus>
|
||||
apply: (opts?: DesktopUpdateApplyOptions) => Promise<DesktopUpdateApplyResult>
|
||||
getBranch: () => Promise<{ branch: string }>
|
||||
setBranch: (name: string) => Promise<{ branch: string }>
|
||||
|
||||
@@ -99,7 +99,8 @@ const {
|
||||
resetUpdateApplyState,
|
||||
startUpdatePoller,
|
||||
stopUpdatePoller,
|
||||
$updateStatus
|
||||
$updateStatus,
|
||||
BACKGROUND_UPDATE_CHECK_MS
|
||||
} = await import('./updates')
|
||||
|
||||
const { setConnection } = await import('./session')
|
||||
@@ -1356,24 +1357,27 @@ describe('startUpdatePoller', () => {
|
||||
it('calls checkUpdates() on startup so the version pill populates immediately', async () => {
|
||||
startUpdatePoller()
|
||||
|
||||
// checkUpdates() is async — flush microtasks without advancing the 30-min interval.
|
||||
// checkUpdates() is async — flush microtasks without advancing the daily interval.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(checkMock).toHaveBeenCalled()
|
||||
expect($updateStatus.get()?.behind).toBe(5)
|
||||
})
|
||||
|
||||
it('calls checkUpdates() on each interval tick', async () => {
|
||||
it('polls once per day and never forces past the caches', async () => {
|
||||
startUpdatePoller()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(checkMock).toHaveBeenCalledWith({ force: false })
|
||||
checkMock.mockClear()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_UPDATE_CHECK_MS - 1)
|
||||
expect(checkMock).not.toHaveBeenCalled()
|
||||
|
||||
expect(checkMock).toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(checkMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls checkUpdates() when the window regains focus', async () => {
|
||||
it('window focus only re-checks once the daily cadence has elapsed', async () => {
|
||||
startUpdatePoller()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
checkMock.mockClear()
|
||||
@@ -1381,9 +1385,12 @@ describe('startUpdatePoller', () => {
|
||||
// Invoke the registered focus handler directly (the mock window doesn't
|
||||
// propagate DOM events, so call the stored listener).
|
||||
listeners['focus']?.()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(checkMock).not.toHaveBeenCalled()
|
||||
|
||||
expect(checkMock).toHaveBeenCalled()
|
||||
vi.setSystemTime(Date.now() + BACKGROUND_UPDATE_CHECK_MS)
|
||||
listeners['focus']?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(checkMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
|
||||
export const openUpdateOverlayFor = (target: UpdateTarget) => {
|
||||
$updateOverlayTarget.set(target)
|
||||
$updateOverlayOpen.set(true)
|
||||
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
|
||||
void (target === 'backend' ? checkBackendUpdates({ force: true }) : checkUpdates({ force: true }))
|
||||
}
|
||||
|
||||
export const resetUpdateApplyState = () => {
|
||||
@@ -386,7 +386,19 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
/**
|
||||
* `force` bypasses every cache (Electron's 24h on-disk cache, the backend's
|
||||
* `.update_check`). Only user-initiated checks pass it — the menu item,
|
||||
* Settings "Check now", opening the overlay. The background poller never does:
|
||||
* each forced check is a real GitHub round trip from every running client.
|
||||
*/
|
||||
export interface UpdateCheckOptions {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export async function checkBackendUpdates({
|
||||
force = false
|
||||
}: UpdateCheckOptions = {}): Promise<DesktopUpdateStatus | null> {
|
||||
if (!isRemoteMode() || $backendUpdateChecking.get()) {
|
||||
return $backendUpdateStatus.get()
|
||||
}
|
||||
@@ -394,7 +406,7 @@ export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null>
|
||||
$backendUpdateChecking.set(true)
|
||||
|
||||
try {
|
||||
const status = mapBackendCheck(await checkHermesUpdate(true))
|
||||
const status = mapBackendCheck(await checkHermesUpdate(force))
|
||||
$backendUpdateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status, 'backend')
|
||||
|
||||
@@ -415,7 +427,7 @@ export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null>
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
export async function checkUpdates({ force = false }: UpdateCheckOptions = {}): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
if (!bridge || $updateChecking.get()) {
|
||||
@@ -425,7 +437,7 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
$updateChecking.set(true)
|
||||
|
||||
try {
|
||||
const status = await bridge.check()
|
||||
const status = await bridge.check({ force })
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status, 'client')
|
||||
void refreshDesktopVersion()
|
||||
@@ -562,7 +574,7 @@ function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
|
||||
if (returned) {
|
||||
$backendUpdateApply.set(IDLE)
|
||||
setUpdateOverlayOpen(false)
|
||||
void checkBackendUpdates()
|
||||
void checkBackendUpdates({ force: true })
|
||||
// The update restarted the gateway process, which strands this window's
|
||||
// WebSocket: over SSH/tailscale tunnels the old TCP connection often dies
|
||||
// without a close event, so connectionState still reads 'open' while every
|
||||
@@ -823,7 +835,7 @@ async function maybeNudgeClientAfterBackendUpdate(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const status = (await checkUpdates().catch(() => null)) ?? $updateStatus.get()
|
||||
const status = (await checkUpdates({ force: true }).catch(() => null)) ?? $updateStatus.get()
|
||||
|
||||
if (!status || status.error || (!status.updateAvailable && (status.behind ?? 0) <= 0)) {
|
||||
return
|
||||
@@ -941,12 +953,12 @@ async function runEverythingUpdate(): Promise<void> {
|
||||
// 3. The client last — its apply relaunches or hands off the app, so it
|
||||
// must come after every dispatch above. Skipped when already current.
|
||||
// Re-check rather than trusting `$updateStatus`: the cached value can be
|
||||
// up to a poll interval (30 min) old and was captured BEFORE the backend
|
||||
// up to a poll interval (24h) old and was captured BEFORE the backend
|
||||
// update above, so a cached `behind: 0` would skip the client leg and
|
||||
// leave the app stale — the exact failure this flow exists to prevent.
|
||||
// `checkUpdates()` resolves with an error-status rather than rejecting,
|
||||
// so fall back to the pre-flow snapshot when the live check can't answer.
|
||||
const freshClientStatus = await checkUpdates().catch(() => null)
|
||||
const freshClientStatus = await checkUpdates({ force: true }).catch(() => null)
|
||||
const clientStatus = freshClientStatus?.error ? cachedClientStatus : (freshClientStatus ?? cachedClientStatus)
|
||||
|
||||
if ((clientStatus?.behind ?? 0) > 0 || clientStatus?.updateAvailable) {
|
||||
@@ -985,10 +997,29 @@ function ingestProgress(payload: DesktopUpdateProgress): void {
|
||||
|
||||
let pollerStarted = false
|
||||
let backgroundTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastFocusAt = 0
|
||||
let connectionUnsub: (() => void) | null = null
|
||||
let lastConnectionMode: string | undefined
|
||||
|
||||
// Passive checks run at most once per day per client. The main process and the
|
||||
// backend each keep a 24h cache, so a tick or focus that lands inside the window
|
||||
// is answered locally — the interval just decides how often we ask.
|
||||
export const BACKGROUND_UPDATE_CHECK_MS = 24 * 60 * 60 * 1000
|
||||
// Focus re-checks are bounded by the same day-long cadence, tracked here so a
|
||||
// user who alt-tabs every minute never turns focus into a poll.
|
||||
const FOCUS_RECHECK_KEY = 'hermes.updates.last-passive-check'
|
||||
|
||||
function passiveCheckDue(now: number): boolean {
|
||||
const last = Number(storedString(FOCUS_RECHECK_KEY) ?? 0)
|
||||
|
||||
return !Number.isFinite(last) || now - last >= BACKGROUND_UPDATE_CHECK_MS
|
||||
}
|
||||
|
||||
function runPassiveChecks(): void {
|
||||
persistString(FOCUS_RECHECK_KEY, String(Date.now()))
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
}
|
||||
|
||||
/** Wire up background polling + progress streaming. Idempotent. */
|
||||
export function startUpdatePoller(): void {
|
||||
if (pollerStarted || typeof window === 'undefined') {
|
||||
@@ -1002,8 +1033,7 @@ export function startUpdatePoller(): void {
|
||||
}
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
runPassiveChecks()
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
@@ -1023,13 +1053,7 @@ export function startUpdatePoller(): void {
|
||||
})
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
backgroundTimer = setInterval(
|
||||
() => {
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
},
|
||||
30 * 60 * 1000
|
||||
)
|
||||
backgroundTimer = setInterval(runPassiveChecks, BACKGROUND_UPDATE_CHECK_MS)
|
||||
}
|
||||
|
||||
export function stopUpdatePoller(): void {
|
||||
@@ -1046,14 +1070,9 @@ export function stopUpdatePoller(): void {
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
const now = Date.now()
|
||||
|
||||
if (now - lastFocusAt < 5 * 60 * 1000) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
|
||||
if (passiveCheckDue(Date.now())) {
|
||||
runPassiveChecks()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user