diff --git a/docs/superpowers/plans/2026-08-03-version-update-check.md b/docs/superpowers/plans/2026-08-03-version-update-check.md new file mode 100644 index 0000000..7fef3ce --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-version-update-check.md @@ -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=` (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 ``. +- 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. diff --git a/src/app/api/system/version/download/route.test.ts b/src/app/api/system/version/download/route.test.ts new file mode 100644 index 0000000..ad71201 --- /dev/null +++ b/src/app/api/system/version/download/route.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { POST } = await import("./route"); + +const RESULT = { + version: "0.2.2", + file: "evoscientist-0.2.2-py3-none-any.whl", + path: "/ws/.evoscientist/updates/v0.2.2/evoscientist-0.2.2-py3-none-any.whl", + suggested_command: "uv pip install /ws/.evoscientist/updates/v0.2.2/...", +}; + +function req(body?: unknown): NextRequest { + return new NextRequest(new URL("http://localhost/api/system/version/download"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +describe("POST /api/system/version/download", () => { + it("proxies an empty body when none is given", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT }); + const res = await POST(req()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(RESULT); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/version/download", + { method: "POST", body: {} } + ); + }); + + it("forwards an explicit version", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT }); + await POST(req({ version: "0.2.2" })); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.anything(), + "/internal/system/version/download", + { method: "POST", body: { version: "0.2.2" } } + ); + }); +}); diff --git a/src/app/api/system/version/download/route.ts b/src/app/api/system/version/download/route.ts new file mode 100644 index 0000000..06e59b6 --- /dev/null +++ b/src/app/api/system/version/download/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export interface SystemVersionDownloadResult { + version: string; + file: string; + path: string; + suggested_command: string; +} + +/** Stage a release artifact on the backend host; never auto-installs. */ +export async function POST(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const body: unknown = await request.json().catch(() => null); + const version = + body && typeof body === "object" && "version" in body + ? (body as { version: unknown }).version + : undefined; + const { body: result } = + await configApiFetch( + actor, + "/internal/system/version/download", + { + method: "POST", + body: typeof version === "string" ? { version } : {}, + } + ); + return NextResponse.json(result, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/api/system/version/route.test.ts b/src/app/api/system/version/route.test.ts new file mode 100644 index 0000000..158c615 --- /dev/null +++ b/src/app/api/system/version/route.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })), + configApiFetch: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + ActorError: class ActorError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, + ConfigApiError: class ConfigApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details: unknown[] = [] + ) { + super(message); + } + }, +})); + +const { GET } = await import("./route"); +const { ConfigApiError } = await import("@/lib/server/evoscientistConfigClient"); + +const INFO = { + current_version: "0.2.2", + latest_version: "0.2.2", + has_update: false, + release_url: null, + release_notes: null, + published_at: null, + cached: false, + warning: null, +}; + +function req(url: string): NextRequest { + return new NextRequest(new URL(url, "http://localhost")); +} + +describe("GET /api/system/version", () => { + it("proxies the backend version endpoint", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO }); + const res = await GET(req("http://localhost/api/system/version")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(INFO); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ sub: "tester" }), + "/internal/system/version" + ); + }); + + it("forwards force=true", async () => { + mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO }); + await GET(req("http://localhost/api/system/version?force=true")); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + expect.anything(), + "/internal/system/version?force=true" + ); + }); + + it("surfaces backend failures", async () => { + mocks.configApiFetch.mockRejectedValue( + new ConfigApiError("backend down", 502, "UPSTREAM") + ); + const res = await GET(req("http://localhost/api/system/version")); + expect(res.status).toBe(502); + expect(await res.json()).toMatchObject({ code: "UPSTREAM" }); + }); +}); diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts new file mode 100644 index 0000000..be845fd --- /dev/null +++ b/src/app/api/system/version/route.ts @@ -0,0 +1,43 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export interface SystemVersionInfo { + current_version: string; + latest_version: string; + has_update: boolean; + release_url: string | null; + release_notes: string | null; + published_at: string | null; + cached: boolean; + warning: string | null; +} + +/** Installed vs latest published EvoScientist version. Any authenticated + * user may call this; the backend enforces the system:read scope. The + * backend caches upstream checks for 20 minutes; `?force=true` bypasses. */ +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + const force = request.nextUrl.searchParams.get("force") === "true"; + const { body } = await configApiFetch( + actor, + `/internal/system/version${force ? "?force=true" : ""}` + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} diff --git a/src/app/components/VersionBadge.tsx b/src/app/components/VersionBadge.tsx new file mode 100644 index 0000000..ef23f66 --- /dev/null +++ b/src/app/components/VersionBadge.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import useSWR from "swr"; +import { Copy, Download, ExternalLink, RefreshCw, Tag } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { errorToast } from "@/lib/errorReporter"; +import type { SystemVersionInfo } from "@/app/api/system/version/route"; +import type { SystemVersionDownloadResult } from "@/app/api/system/version/download/route"; + +async function fetchVersion(url: string): Promise { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + body && typeof body.message === "string" + ? body.message + : `Version check failed (${res.status}).` + ); + } + return (await res.json()) as SystemVersionInfo; +} + +export function VersionBadge() { + const [open, setOpen] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [downloading, setDownloading] = useState(false); + const [downloaded, setDownloaded] = + useState(null); + const { data, error, mutate } = useSWR( + "/api/system/version", + fetchVersion, + { revalidateOnFocus: false, shouldRetryOnError: false } + ); + + const handleRefresh = async () => { + setRefreshing(true); + try { + const info = await fetchVersion("/api/system/version?force=true"); + await mutate(info, { revalidate: false }); + } catch (e) { + errorToast( + "version.refresh", + e instanceof Error ? e.message : "Couldn't check for updates." + ); + } finally { + setRefreshing(false); + } + }; + + const handleDownload = async () => { + setDownloading(true); + try { + const res = await fetch("/api/system/version/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + body && typeof body.message === "string" + ? body.message + : body && typeof body.error === "string" + ? body.error + : `Download failed (${res.status}).` + ); + } + setDownloaded(body as SystemVersionDownloadResult); + } catch (e) { + errorToast( + "version.download", + e instanceof Error ? e.message : "Couldn't download the update." + ); + } finally { + setDownloading(false); + } + }; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setDownloaded(null); + }; + + const hasUpdate = data?.has_update === true; + + return ( + + + + + +
+ Version +
+ {error && !data ? ( +

+ Couldn't load version info. +

+ ) : !data ? ( +

+ Loading... +

+ ) : ( +
+
+ Installed + v{data.current_version} +
+
+ Latest + + v{data.latest_version} + +
+ {hasUpdate && ( +

+ A new version of EvoScientist is available. +

+ )} + {data.warning && ( +

+ Last check failed: {data.warning} +

+ )} + {hasUpdate && !downloaded && ( + + )} + {downloaded && ( +
+

+ {downloaded.path} +

+
+ + {downloaded.suggested_command} + + +
+

+ Run this on the backend host, then restart it. +

+
+ )} +
+ )} +
+ {data?.release_url && ( + + )} + +
+
+
+ ); +} diff --git a/src/lib/server/delegation.ts b/src/lib/server/delegation.ts index 2f42561..5379b3a 100644 --- a/src/lib/server/delegation.ts +++ b/src/lib/server/delegation.ts @@ -25,6 +25,7 @@ export const SCOPE_MODEL_CONFIG_WRITE = "model_config:write"; export const SCOPE_MODEL_CONFIG_TEST = "model_config:test"; export const SCOPE_MODEL_SELECT = "model:select"; export const SCOPE_RUN_CREATE = "run:create"; +export const SCOPE_SYSTEM_READ = "system:read"; const ADMIN_SCOPES = [ SCOPE_MODEL_CONFIG_READ, @@ -32,9 +33,10 @@ const ADMIN_SCOPES = [ SCOPE_MODEL_CONFIG_TEST, SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE, + SCOPE_SYSTEM_READ, ] as const; -const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE] as const; +const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE, SCOPE_SYSTEM_READ] as const; /** Fixed role → scopes mapping (design doc 7.2). */ export function scopesForRole(role: UserRole): string[] {