fix(desktop): map behind: -1 to null instead of clamping the sentinel to 0

The backend update-check endpoint answers behind: -1
(source_check.UPDATE_AVAILABLE_NO_COUNT) when the checkout is behind but
the count can't be computed — a shallow clone without a merge-base or an
unusable GitHub compare. mapBackendCheck clamped it to 0, making that
state byte-identical to the genuinely-up-to-date answer while
update_available still pitched the install, so every behind-based branch
disagreed with the overlay on the same screen and the changelog rendered
"what changed" over zero rows.

DesktopUpdateStatus already types this state as null ("never render it
as a literal number"), so pass the sentinel through as null. The
check-failed predicate is unchanged: it still keys on behind === null
with can_apply, which -1 correctly does not trigger (the check did run).

Also updates the pre-existing updates.test.ts expectation that encoded
the old clamp, and pins the sentinel in updates-backend-check.test.ts.
This commit is contained in:
Brooklyn Nicholson
2026-09-26 21:55:48 -05:00
committed by brooklyn!
parent 31a68e9235
commit 69ccfa69e4
3 changed files with 27 additions and 6 deletions

View File

@@ -47,12 +47,25 @@ describe('mapBackendCheck', () => {
expect(status.targetSha).toBe('backend:9.9.9')
})
it('maps the "behind but uncountable" sentinel to behind: null, not 0', () => {
// The producer answers behind: -1 (source_check.UPDATE_AVAILABLE_NO_COUNT)
// when the checkout is behind but the count can't be computed (shallow
// clone without a merge-base, unusable compare API). DesktopUpdateStatus
// types that state as null — "never render it as a literal number" — so
// clamping -1 to 0 made it byte-identical to the up-to-date answer while
// updateAvailable still pitched the install.
const status = mapBackendCheck(response({ behind: -1, update_available: true }))
expect(status.error).toBeUndefined()
expect(status.behind).toBeNull()
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' })
)
const status = mapBackendCheck(response({ can_apply: false, behind: null, message: 'pip install -U hermes-agent' }))
expect(status.supported).toBe(false)
expect(status.error).toBeUndefined()

View File

@@ -438,7 +438,9 @@ describe('checkBackendUpdates', () => {
const result = await checkBackendUpdates()
expect(result?.behind).toBe(0)
// behind: -1 is the producer's "update available, count unknown" sentinel;
// DesktopUpdateStatus carries it as null, never a literal number.
expect(result?.behind).toBeNull()
expect(result?.updateAvailable).toBe(true)
expect(result?.targetSha).toBe('backend:0.16.0')
})

View File

@@ -489,7 +489,13 @@ function isRemoteMode(): boolean {
}
export function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
const behind = res.behind ?? 0
// The producer's contract (web_routers/actions.py): behind is 0 = up to
// date, -1 = update available but the count is unknown (shallow clone
// without a merge-base, unusable GitHub compare), null = the check could
// not run at all. DesktopUpdateStatus types "unknown count" as null —
// "never render it as a literal number" — so pass that through instead of
// clamping the sentinel to a byte-identical copy of "up to date".
const behind = res.behind === undefined ? 0 : res.behind
// `behind: null` from a supported (git) backend is the endpoint's "the check
// could not run" answer (GitHub unreachable, rate limited, offline) and
@@ -507,7 +513,7 @@ export function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateS
error: checkFailed ? 'check-failed' : undefined,
message: res.message ?? undefined,
updateAvailable: res.update_available,
behind: behind > 0 ? behind : 0,
behind: behind === null || behind < 0 ? null : behind,
currentVersion: res.current_version,
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
commits: res.commits,