feat(webui): version update check with badge and download proxy
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user