Files
EvoScientist-WebUI/docs/superpowers/plans/2026-08-03-version-update-check.md
T

9.6 KiB
Raw Blame History

Version Update Check & Download for OriginEvoScientist

Date: 2026-08-03 Status: Implemented (2026-08-06) Inspired by: sub2api UpdateService + VersionBadge (see ~/Projects/EvoSci/sub2api/sub2api)

Implementation deviations from the original draft:

  • "Latest" is the max semver across /releases?limit=20 and /tags?limit=20 (not releases/latest): Gitea orders releases/latest by tag creation date, so v0.2.2 (tagged 2026-07-11) ranked below v0.1.19 (tagged 2026-07-13).
  • release.sh builds in a temporary git worktree at the tag so a dirty working tree can't leak into artifacts, and supports version == pyproject (reuses the existing tag) for baseline cuts.
  • Backend routes live in langgraph_dev/http.py behind the model-registry BffAuthenticator with a new system:read scope (added to both WebUI roles in src/lib/server/delegation.ts).
  • The Gitea checker extends the existing EvoScientist/update_check.py (PyPI startup check untouched).
  • No component render test for VersionBadge — the repo has no jsdom/testing -library harness (node-env vitest only); verified in-browser via Playwright.

Context

Port sub2api's "version badge + update prompt" to OriginEvoScientist, using the self-hosted Gitea instance git.foksai.com (verified: Gitea 1.26.4, anonymous read access, GitHub-compatible release schema) as the release source.

Verified facts (2026-08-03):

  • GET https://git.foksai.com/api/v1/repos/ouyangbo/EvoScientist/releases/latest → 200, tag_name=v0.1.19, schema matches GitHub (tag_name/name/body/html_url/ draft/prerelease/tarball_url/assets).
  • Latest git tag is v0.1.20 (tag without a release exists).
  • ouyangbo/EvoScientist-WebUI has no releases and no tags (404 from releases/latest — must be treated as "never published", not an error).
  • Local pyproject.toml is already 0.2.2 → release baseline lags behind the code; Part 1 must cut a fresh release first or has_update can never fire.
  • Asset/attachment URLs returned by the API use the git.foksai.com:8443 host (instance's HTTPS port); the download allowlist must compare host, ignoring port or allowing :8443 explicitly.

Non-goal: automatic in-place upgrade. EvoScientist runs from source (Python venv + Next.js build), so sub2api's atomic-binary-swap does not translate. We download + verify + present the install command; the operator applies it.

Decisions

Question Decision
What counts as "latest"? releases/latest; if 404, fall back to max semver tag via /tags?limit=20
Which repo/component? ouyangbo/EvoScientist (backend) only; WebUI deferred until it starts tagging
Current version source importlib.metadata.version("EvoScientist") (pyproject is the single source)
Release creation Local scripts/release.sh + Gitea API token; Gitea Actions later (instance capability unverified)
Auto-apply No — download to staging dir, show install command

Env config (backend):

  • EVOSCIENTIST_UPDATE_BASE_URL — default https://git.foksai.com
  • EVOSCIENTIST_UPDATE_REPO — default ouyangbo/EvoScientist
  • EVOSCIENTIST_UPDATE_CHECK_DISABLED — set to 1 to disable outbound checks
  • GITEA_TOKEN — only needed by release.sh (writes), not by the checker

Part 1 — Publishing new versions (EvoScientist repo)

Task 1.1: scripts/release.sh

Inputs: scripts/release.sh 0.2.3 "Release notes text" (version arg required; script refuses if it doesn't differ from pyproject).

Steps:

  1. sed bump version = "X.Y.Z" in pyproject.toml; uv lock (if lockfile tracks version); commit chore(release): vX.Y.Z; git tag vX.Y.Z; git push origin main --tags.
  2. uv build → dist/EvoScientist-X.Y.Z.tar.gz, dist/evoscientist-X.Y.Z-*.whl.
  3. shasum -a 256 dist/* > dist/checksums.txt.
  4. Create release via API: POST $BASE/api/v1/repos/ouyangbo/EvoScientist/releases with {tag_name, name: "Release vX.Y.Z", body}; header Authorization: token $GITEA_TOKEN.
  5. Upload each asset: POST /repos/.../releases/{id}/assets?name=<fname> (binary body).
  6. Verify: GET releases/latest returns the new tag.

Task 1.2: cut baseline release v0.2.2 — run the script once so the checker has a meaningful comparison target.

Task 1.3 (deferred): Gitea Actions — probe GET /api/v1/repos/ouyangbo/EvoScientist/actions/tasks (or instance admin) to see if Actions is enabled; if yes, port .github/workflows/build.yml into .gitea/workflows/release.yml triggered on push: tags: ["v*"] doing steps 2–5. Not required for v1.


Part 2 — Update check

Task 2.1: backend module EvoScientist/update_check.py (new file)

class UpdateInfo(TypedDict):
    current_version: str
    latest_version: str
    has_update: bool
    release_url: str | None
    release_notes: str | None
    published_at: str | None
    cached: bool
    warning: str | None

def get_update_info(*, force: bool = False) -> UpdateInfo
  • Current version: importlib.metadata.version("EvoScientist"), fallback "0.0.0-dev" on PackageNotFoundError.
  • Latest: GET {base}/api/v1/repos/{repo}/releases/latest (10s timeout).
    • 404 → GET {base}/api/v1/repos/{repo}/tags?limit=20, take max semver.
    • Other network/HTTP error → return last cached value with warning set; if no cache, latest = current, has_update = False, warning set.
  • Version compare: strip leading v, split ., int-compare 3 segments (mirror sub2api compareVersions, update_service.go:641); non-numeric segments compare as 0.
  • Cache: module-level {"info": ..., "fetched_at": ...}, TTL 1200s; force bypasses. Mirror sub2api TTL (update_service.go:32).
  • Disabled switch: EVOSCIENTIST_UPDATE_CHECK_DISABLED=1 → immediately return current==latest, no network.
  • HTTP client: httpx (already a dependency).

Task 2.2: backend route in EvoScientist/langgraph_dev/http.py (alongside the existing /internal/workspace-scopes/* routes, ~line 878):

  • GET /internal/system/version?force=true → JSON from get_update_info.

Task 2.3: WebUI BFF src/app/api/system/version/route.ts (new)

  • GET proxies to {backend}/internal/system/version, forwards force query param, returns backend JSON; on backend failure → 502 with the existing error-shape used by other BFF routes (mirror src/app/api/skills/route.ts).

Task 2.4: WebUI component src/app/components/VersionBadge.tsx (new)

Modeled on sub2api VersionBadge.vue and our ErrorLogBell.tsx:

  • Button showing v{current_version}; when has_update: amber background + ping-dot indicator.
  • Dropdown: current version, latest version, "View release notes" link (release_url, opens new tab), refresh button (force=true), and the download section from Part 3.
  • SWR over /api/system/version, fetch on mount + on dropdown open; no aggressive polling (server caches 20 min anyway).
  • Mount in src/app/page.tsx header next to <ErrorLogBell />.
  • All strings English.

Part 3 — Update download

Task 3.1: backend download — extend update_check.py:

def download_update(version: str | None = None) -> DownloadResult
  1. Resolve target release: releases/latest or GET /repos/{repo}/releases/tags/{version} for a specific version.
  2. Pick asset, in priority order: a. evoscientist-X.Y.Z-py3-none-any.whl (from assets[].name, browser_download_url) b. sdist EvoScientist-X.Y.Z.tar.gz c. tarball_url fallback
  3. Host allowlist: parsed asset URL host must equal the update base URL's host (allow port :8443); reject everything else (SSRF guard, mirror validateDownloadURL at update_service.go:447).
  4. Stream-download with 200 MB cap to {workspace}/.evoscientist/updates/v{X.Y.Z}/ (create dir; workspace = EVOSCIENTIST_WORKSPACE_DIR).
  5. If the release has a checksums.txt asset, download it and verify sha256 of the chosen file; fail hard on mismatch, delete the file.
  6. Return {version, file, path, suggested_command} where suggested_command = f"uv pip install {path} # then restart the backend".
  • Route: POST /internal/system/version/download, body {"version": optional}, timeout 120s.

Task 3.2: WebUI — BFF src/app/api/system/version/download/route.ts (POST proxy), and in VersionBadge dropdown a "Download update" button (disabled while running; shows spinner, then success path + copyable install command; errorToast on failure, mirroring ErrorLogBell patterns).


Tests

Backend (EvoScientist/tests/ or colocated, pytest):

  • semver compare: v0.1.19 < 0.2.2, equal, malformed segments
  • 404 on releases/latest → falls back to tags → still 404 → latest=current
  • cache: second call within TTL does not hit network (mock httpx)
  • force=True bypasses cache
  • download: asset priority (wheel over sdist), host allowlist rejects evil.com, size cap aborts, checksum mismatch deletes file
  • disabled env var short-circuits

WebUI (vitest, node env — same as existing route.test.ts files):

  • BFF GET proxies + forwards force
  • BFF POST download proxies body
  • Badge renders amber/dot only when has_update

Rollout order

  1. Part 1 script + baseline release v0.2.2 (validates the publishing path)
  2. Task 2.1 + 2.2 (backend check) with tests
  3. Task 2.3 + 2.4 (WebUI badge), browser-verify with playwright
  4. Task 3.1 + 3.2 (download), verify a real v0.2.2 download end-to-end

Open risks

  • Gitea instance may require auth for API reads in the future → checker should accept optional EVOSCIENTIST_UPDATE_TOKEN header from the start.
  • Release URLs embed :8443; if the instance later moves to 443, allowlist logic must not hard-code the port.