feat(webui): version update check with badge and download proxy

This commit is contained in:
m4
2026-08-11 15:07:53 +08:00
parent 141e25c3b0
commit 598b2cfdc4
7 changed files with 702 additions and 1 deletions
@@ -0,0 +1,226 @@
# 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)
```python
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`:
```python
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.