Design — pre-flight /simple/ agreement check for tray update discovery

Status:approved-by-user
Date:2026-05-20
Branch:master
Type:spec

Problem

Tray-triggered "Update" can land at a version different from the one the tray advertised. Surfaced live on the v0.45.2 → v0.45.3 upgrade: the tray's "Update available" badge said 0.45.3, but after install the message read "Update complete — jacked is now v0.45.2". The user gets a confusing "you clicked install but didn't get what was promised" experience and is left wondering whether the update actually worked.

Goal

The tray's "Update available" indicator must be a truthful promise: when the user clicks it, the target version is guaranteed to be installable. Never show a phantom update.

Non-goals

Design

New function: get_latest_from_simple_index

Probes https://pypi.org/simple/<package>/ using the PEP 691 JSON variant (single Accept header, single small response, no HTML parsing required), extracts versions from the files[].filename entries, returns the max via the existing tuple-comparison parser.

def get_latest_from_simple_index(package: str = "claude-jacked", timeout: float = 3.0) -> str | None:
    """Query PEP 691 JSON variant of /simple/ index. Returns max version, or None on failure.

    This is THE SAME index uv reads (uv consumes /simple/, not /pypi/json), so a version's
    presence here guarantees `uv tool install --refresh` will resolve to it.
    """
    try:
        url = f"https://pypi.org/simple/{package}/"
        req = urllib.request.Request(
            url, headers={"Accept": "application/vnd.pypi.simple.v1+json"}
        )
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read())
        versions = set()
        for entry in data.get("files", []):
            fn = entry.get("filename", "")
            # claude_jacked-0.45.3-py3-none-any.whl or claude_jacked-0.45.3.tar.gz
            m = _VERSION_FROM_FILENAME.search(fn)
            if m:
                versions.add(m.group(1))
        if not versions:
            return None
        # Pick max using the existing is_newer tuple-compare parser
        return max(versions, key=lambda v: _parse_version_tuple(v))
    except Exception:
        return None

Modified function: check_version_cached

Reads BOTH endpoints, treats the lesser as the "installable latest". outdated is true only when the installable latest is newer than the currently-installed version.

def check_version_cached(current_version: str, force: bool = False) -> dict | None:
    # ... existing cache-read shortcut unchanged ...

    pypi_latest = get_latest_pypi_version()
    simple_latest = get_latest_from_simple_index()

    # Conservative: if either probe failed, do not surface an update.
    if not pypi_latest or not simple_latest:
        installable = current_version
    else:
        # The lesser of the two is what uv would actually install.
        installable = pypi_latest if not is_newer(simple_latest, pypi_latest) else simple_latest
        # i.e. min(pypi, simple) by version order

    outdated = is_newer(installable, current_version)

    cache.update({
        "checked_at": now,
        "pypi_latest": pypi_latest,
        "simple_latest": simple_latest,
        "installable_latest": installable,
        "current": current_version,
        "outdated": outdated,
    })
    # ... persist, return ...

Cache migration

Old cache schema:

{"latest": "0.45.2", "checked_at": 1747768320}

New schema:

{"pypi_latest": "0.45.3", "simple_latest": "0.45.2", "installable_latest": "0.45.2",
 "current": "0.45.2", "outdated": false, "checked_at": 1747768320}

Cache reader returns None when it can't find the new fields (treated as "no cache, must re-fetch"). One free re-fetch on first run after upgrade; subsequent reads work normally. No explicit migration code needed.

Failure handling

ScenarioBehavior
Both endpoints OK, both agreeoutdated = true if newer than current
Both endpoints OK, /pypi/json ahead of /simple/outdated = false (use the lower)
/simple/ unreachableoutdated = false (conservative)
/pypi/json unreachableoutdated = false (conservative)
Both unreachableoutdated = false
/simple/ returns empty file list (network ok, but no wheels)outdated = false (defensive)

Tray UI impact

Zero. The tray reads cache["outdated"] via the existing version_text_fn / version_enabled_fn callbacks. The fact that "outdated" is now derived from two endpoints instead of one is opaque to the tray. No new menu items, no new visual states.

Tests

TestAsserts
test_get_latest_from_simple_parses_pep691given a real-shaped /simple/ JSON response, returns the max version
test_get_latest_from_simple_handles_network_errorreturns None on URL error, timeout, JSON decode error
test_get_latest_from_simple_empty_files_returns_nonedefensive — empty files array → None
test_get_latest_from_simple_picks_max_not_firstmultiple versions in files[]; must return the max
test_outdated_false_when_simple_lags_pypiregression for v0.45.2→0.45.3 propagation lag
test_outdated_true_when_both_endpoints_agreehappy path
test_outdated_false_when_simple_unreachableconservative behavior on network failure
test_outdated_false_when_pypi_unreachablesame, other endpoint
test_old_cache_schema_triggers_refetchmigration — old-format cache returns None / forces re-poll

File scope

FileChange
jacked/version_check.pyAdd get_latest_from_simple_index + filename regex + tuple parser export; modify check_version_cached to use both endpoints; update cache schema
tests/unit/test_version_check.pyAdd the 9 tests above
jacked/__init__.pyBump version to 0.45.4

Success criteria

  1. v0.45.4 → v0.45.5-style propagation lag never shows a phantom "Update available" button
  2. Once /simple/ catches up (typically minutes later), the button appears normally
  3. Both happy-path and degraded-network paths covered by tests
  4. No tray UI changes; no behavior change for users currently on the latest version
  5. Old version cache file is gracefully discarded — no migration script needed