fix(desktop): report a failed backend update check instead of "up to date"

Salvaged from #119803 by Overview3833 (rebased onto current main; the
diff itself applies unchanged).

`mapBackendCheck` folded the backend's `behind: null` into `behind: 0` and
returned a status without `error`. But `null` is the endpoint's "the check
could not run" answer — GitHub unreachable, rate limited, offline — and it
carries the explanation in `message`.

The remote overlay therefore fell through to the all-set copy ("the backend is
on the latest version") whenever the check failed, and the message the backend
sent was never shown. The local check path already reports `error:
'check-failed'` in this situation; only the backend mapping dropped it.

Map a null from a supported (git) backend to the same failure state, so the
overlay's `status.error` branch shows the backend's message with its retry
affordance (and the "update available" toast stays suppressed, since we don't
know the real distance). A check that ran and found no gap still reports
"latest", and backends that cannot self-update keep taking the unsupported
branch.

Fixes #119801
This commit is contained in:
Brooklyn Nicholson
2026-09-24 18:29:03 -05:00
committed by brooklyn!
parent 89cc403b23
commit fa65609351
2 changed files with 74 additions and 1 deletions

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import type { BackendUpdateCheckResponse } from '@/types/hermes'
import { mapBackendCheck } from './updates'
const response = (over: Partial<BackendUpdateCheckResponse> = {}): BackendUpdateCheckResponse => ({
install_method: 'git',
current_version: '9.9.9',
behind: 0,
update_available: false,
can_apply: true,
update_command: null,
message: null,
...over
})
describe('mapBackendCheck', () => {
it('surfaces a failed check instead of claiming the backend is up to date', () => {
// The endpoint answers `behind: null` + a message when it could not run the check
// (GitHub unreachable, rate limited, offline). Folding that to 0 reported "you're on
// the latest version" for a check that never happened.
const status = mapBackendCheck(
response({ behind: null, message: "Couldn't reach the update source — try again later." })
)
expect(status.error).toBe('check-failed')
expect(status.updateAvailable).not.toBe(true)
expect(status.message).toContain("Couldn't reach the update source")
expect(status.targetSha).toBeUndefined()
})
it('still reports an up-to-date backend when the check ran and found no gap', () => {
const status = mapBackendCheck(response({ behind: 0, message: "You're on the latest version." }))
expect(status.error).toBeUndefined()
expect(status.behind).toBe(0)
expect(status.updateAvailable).not.toBe(true)
})
it('keeps a real gap intact', () => {
const status = mapBackendCheck(response({ behind: 3, update_available: true }))
expect(status.error).toBeUndefined()
expect(status.behind).toBe(3)
expect(status.updateAvailable).toBe(true)
expect(status.targetSha).toBe('backend:9.9.9')
})
it('leaves a backend that cannot self-update to the unsupported branch', () => {
// pip/nix and managed runtimes also answer `behind: null`, but `can_apply: false` renders
// the "not available" copy first — those must not be turned into failures.
const status = mapBackendCheck(
response({ can_apply: false, behind: null, message: 'pip install -U hermes-agent' })
)
expect(status.supported).toBe(false)
expect(status.error).toBeUndefined()
expect(status.message).toBe('pip install -U hermes-agent')
})
})

View File

@@ -488,11 +488,23 @@ function isRemoteMode(): boolean {
return $connection.get()?.mode === 'remote'
}
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
export function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
const behind = res.behind ?? 0
// `behind: null` from a supported (git) backend is the endpoint's "the check
// could not run" answer (GitHub unreachable, rate limited, offline) and
// carries the explanation in `message`. Folding it to 0 made the overlay
// report "the backend is on the latest version" whenever the check failed,
// and hid that message. Surface the failure state the local check path
// already uses so the overlay shows the reason and a retry; only a check
// that actually ran may claim there is nothing to update. Backends that
// cannot self-update also answer `behind: null`, but `can_apply: false`
// renders the unsupported copy first — those must not become failures.
const checkFailed = res.can_apply && res.behind === null
return {
supported: res.can_apply,
error: checkFailed ? 'check-failed' : undefined,
message: res.message ?? undefined,
updateAvailable: res.update_available,
behind: behind > 0 ? behind : 0,