feat(core,server,web,cli): show version in the web UI with update check and admin self-update (#74)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 23:34:06 +08:00
committed by GitHub
parent 033d526f9f
commit e1141ca010
27 changed files with 1375 additions and 40 deletions
+55
View File
@@ -1250,3 +1250,58 @@ export interface AgentSkillsResponse {
export interface SkillInstallRequest {
names: string[];
}
// ---------------------------------------------------------------------------
// Version and self-update
// ---------------------------------------------------------------------------
/** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */
export interface VersionResponse {
version: string;
/**
* The **running** version's release date (UTC yyyy-mm-dd), stamped into core's
* BUILD_DATE at build time by the release workflow — the web's "last updated" date
* needs no network. Null for a dev/source build and for releases that predate the
* stamping (v0.1.2 and earlier): the UI then shows the version alone.
*/
buildDate: string | null;
}
/**
* GET /api/version/update-check: newest published release vs the running version.
* Always HTTP 200 (fail-soft): a lookup failure sets `error` and leaves `latestVersion`
* null rather than failing the request; results are cached server-side.
*/
export interface UpdateCheckResponse {
currentVersion: string;
/** Same as VersionResponse.buildDate: the running version's release date, stamped at build time. */
buildDate: string | null;
/** Newest published release (normalized, no leading `v`); null when the lookup failed or checks are disabled. */
latestVersion: string | null;
updateAvailable: boolean;
/** Release page of the newest release (for the "release notes" link). */
releaseUrl: string | null;
/** Publish timestamp of the newest release (ISO 8601). */
publishedAt: string | null;
/** When this result was produced (ISO 8601) — a cached result keeps its original timestamp. */
checkedAt: string;
/** Present (true) when update checks are turned off via PENGUIN_UPDATE_CHECK=off; no network call was made. */
disabled?: true;
/** Why the lookup failed: unreachable network / GitHub rate limit / unusable response. */
error?: "network" | "rate_limited" | "bad_response";
}
/**
* POST /api/version/update (admin only): runs the CLI self-update (`penguin update --yes`)
* on the server host. `unsupported` covers both a server not launched via the CLI and the
* CLI's own refusals (source checkout, unrecognized install layout, Windows).
*/
export interface UpdateRunResponse {
status: "updated" | "failed" | "unsupported";
/** Set when the server cannot run the CLI at all (started without `penguin server|web`). */
reason?: "not_launched_via_cli";
/** Tail of the update command's combined stdout+stderr (capped; empty when nothing ran). */
output: string;
/** True when the install changed (or was already current): restart the service to run the new version. */
needsRestart: boolean;
}
+10
View File
@@ -47,6 +47,7 @@ import { agentConfigRoutes } from "./http/routes/agent-config.js";
import { agentTracesRoutes } from "./http/routes/agent-traces.js";
import { usageRoutes } from "./http/routes/usage.js";
import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
import { versionRoutes } from "./http/routes/version.js";
import { ChannelHub } from "./runtime/channel.js";
import { ErrorRecorder } from "./runtime/error-recorder.js";
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
@@ -65,6 +66,7 @@ import { ProjectConfigService } from "./services/project-config-service.js";
import { ProjectService } from "./services/project-service.js";
import { SessionService } from "./services/session-service.js";
import { TraceService } from "./services/trace-service.js";
import { UpdateCheckService } from "./services/update-check-service.js";
import { UsageService } from "./services/usage-service.js";
import { WorkspaceFilesService } from "./services/workspace-files-service.js";
import {
@@ -93,6 +95,8 @@ export interface AppDeps {
sessionService: SessionService;
traceService: TraceService;
usageService: UsageService;
/** GitHub latest-release lookup for the web UI's update reminder (cached, fail-soft). */
updateCheck: UpdateCheckService;
workspaceFiles: WorkspaceFilesService;
/** Signs/verifies short-lived Workspace preview tokens (separate preview origin). */
previewTokens: PreviewTokenSigner;
@@ -115,6 +119,8 @@ export interface BuildDepsOverrides {
loader?: SessionLoader;
/** Test double: Session title generator (avoids real LLM requests). */
titles?: TitleNotifier;
/** Test double: update-check service with a stubbed fetch/clock (avoids real network calls). */
updateCheck?: UpdateCheckService;
log?: (line: string) => void;
now?: () => Date;
}
@@ -151,6 +157,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
overrides.now ?? (() => new Date()),
);
const updateCheck =
overrides.updateCheck ?? new UpdateCheckService(overrides.now ? { now: overrides.now } : {});
// Channel idle reclamation skips active Sessions (running/compacting can go a long time
// without a publish, e.g. while waiting for approval).
@@ -247,6 +255,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
sessionService,
traceService,
usageService,
updateCheck,
workspaceFiles,
previewTokens,
benchmarks,
@@ -331,6 +340,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
const auth = authMiddleware(deps.authService);
app.use("/api/*", auth);
app.route("/api/me", meRoutes(deps));
app.route("/api/version", versionRoutes(deps));
app.route("/api/admin/users", adminUsersRoutes(deps));
app.route("/api/events", eventsRoutes(deps));
// Skill library listing: readable once logged in, not nested under a Project prefix.
+154
View File
@@ -0,0 +1,154 @@
/**
* Version routes: the running release identity, the update-check reminder, and the
* admin-only self-update.
*
* GET /api/version -> {version, buildDate} from core's VERSION / BUILD_DATE
* GET /api/version/update-check -> UpdateCheckService (fail-soft, cached, opt-out env;
* ?force=1 bypasses the cache for the manual check)
* POST /api/version/update -> admin only: re-runs the CLI as `penguin update --yes`
*
* How the self-update works: `penguin server|web` exports PENGUIN_CLI_ENTRY (its own entry
* script path) before importing this server, and this route re-runs that script as
* `node <entry> update --yes` — the CLI's update command owns all install-kind detection,
* download and replacement logic (packages/cli/src/commands/update.ts). A server started
* any other way (tests, a custom embedding) has no CLI to run and reports "unsupported".
* SECURITY: the spawned argv is a fixed literal list; nothing from the request flows into
* the command, its arguments, or its environment.
*
* Interpreting the CLI's outcome: `penguin update` exits non-zero only when an upgrade was
* attempted and failed; refusals (source checkout, unrecognized layout, Windows) print a
* message and exit 0. Exit codes alone therefore cannot separate "updated" from
* "unsupported", so the spawn forces PENGUIN_LANG=en and classifies by the refusal
* messages' stable English fragments (packages/cli/src/i18n.ts, `update` section). That
* matching is reliable here because the spawned CLI and this server ship in lockstep from
* the same install — the strings can never be from a different release than this code.
*/
import { spawn } from "node:child_process";
import { BUILD_DATE, VERSION } from "@prismshadow/penguin-core";
import { Hono } from "hono";
import type { UpdateRunResponse, VersionResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
/** Only the tail of the update output is kept (installer logs can be long). */
const OUTPUT_CAP = 64 * 1024;
/** The whole update (download + install) must finish within this window. */
const UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* English fragments of the CLI refusal messages (packages/cli/src/i18n.ts `update.*`),
* one per refusal reason. `needsYes` / `cancelled` are omitted: `--yes` makes the
* confirmation gate proceed unconditionally, so neither can occur here.
*/
const REFUSAL_MARKERS = [
"runs from a source checkout", // sourceCheckout
"Cannot tell how this penguin was installed", // unknownInstall
"could not be identified", // npmUnknownManager
"does not run on Windows", // windowsUnsupported
"cannot run your package manager for you", // windowsGlobalInstall
];
/**
* Maps the finished CLI run to an UpdateRunResponse. Exported for unit tests (tests never
* spawn anything real). Exit 0 with a refusal message is "unsupported"; any other exit 0
* is "updated" — including the CLI's "already on the latest version", because that means
* the *install* is current and only a restart of this (older, in-memory) process is
* missing, which is exactly what needsRestart says.
*/
export function classifyUpdateRun(exitCode: number, output: string): UpdateRunResponse {
if (exitCode !== 0) return { status: "failed", output, needsRestart: false };
if (REFUSAL_MARKERS.some((marker) => output.includes(marker))) {
return { status: "unsupported", output, needsRestart: false };
}
return { status: "updated", output, needsRestart: true };
}
/** Runs `node <cliEntry> update --yes` to completion and classifies the outcome. */
function runSelfUpdate(cliEntry: string): Promise<UpdateRunResponse> {
return new Promise((resolve) => {
// PENGUIN_LANG=en pins the CLI's output language so classifyUpdateRun's markers match
// regardless of the deployment's configured CLI language.
const child = spawn(process.execPath, [cliEntry, "update", "--yes"], {
env: { ...process.env, PENGUIN_LANG: "en" },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
const append = (chunk: Buffer) => {
output = (output + chunk.toString("utf8")).slice(-OUTPUT_CAP);
};
child.stdout.on("data", append);
child.stderr.on("data", append);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// A SIGTERM-ignoring child would otherwise leave this request hanging forever.
setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
}, UPDATE_TIMEOUT_MS);
timer.unref();
child.on("error", (err) => {
clearTimeout(timer);
resolve({
status: "failed",
output: `${output}\n[failed to run the update command: ${err.message}]`.trim(),
needsRestart: false,
});
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
resolve({
status: "failed",
output:
`${output}\n[the update run was killed after ${UPDATE_TIMEOUT_MS / 60_000} minutes]`.trim(),
needsRestart: false,
});
return;
}
resolve(classifyUpdateRun(code ?? -1, output.trim()));
});
});
}
export function versionRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
// Two admins clicking "update" at once must not race two installers over the same
// install dir: concurrent requests share the one in-flight run (and its result).
let inflightRun: Promise<UpdateRunResponse> | null = null;
app.get("/", (c) => {
return c.json({ version: VERSION, buildDate: BUILD_DATE } satisfies VersionResponse);
});
app.get("/update-check", async (c) => {
// ?force=1 (the web's manual "check for updates" action) bypasses the TTL cache;
// see UpdateCheckService.check for why a user-initiated press-through is acceptable.
return c.json(await deps.updateCheck.check(c.req.query("force") === "1"));
});
app.post("/update", async (c) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
}
const cliEntry = process.env.PENGUIN_CLI_ENTRY;
if (!cliEntry) {
return c.json({
status: "unsupported",
reason: "not_launched_via_cli",
output: "",
needsRestart: false,
} satisfies UpdateRunResponse);
}
inflightRun ??= runSelfUpdate(cliEntry).finally(() => {
inflightRun = null;
});
return c.json(await inflightRun);
});
return app;
}
@@ -0,0 +1,141 @@
/**
* Update-check service: resolves the newest published release from the GitHub Releases
* API and compares it with the running version (core's VERSION), for the web UI's
* update reminder (GET /api/version/update-check).
*
* This is the server's only outbound internet call, so it is strictly fail-soft: check()
* never throws and never surfaces an upstream failure as a 5xx — a failed lookup returns
* a normal response with `error` set and `latestVersion` null. Outcomes are cached
* in-memory (success for 1 hour, failure for 10 minutes) so a sidebar that opens on every
* page load cannot hammer GitHub's unauthenticated rate limit; concurrent requests share
* one in-flight lookup. `PENGUIN_UPDATE_CHECK=off` disables the lookup entirely (no
* network call), for air-gapped or privacy-sensitive deployments.
*
* The request mirrors the CLI's fetchLatestVersion (packages/cli/src/commands/update.ts):
* same endpoint, same accept / user-agent header shape, same 15s timeout — the server
* cannot import the CLI package (the dependency runs the other way), hence the small
* duplication. fetch and the clock are injectable so tests never touch the network.
*/
import { BUILD_DATE, VERSION, compareVersions, normalizeVersion } from "@prismshadow/penguin-core";
import type { UpdateCheckResponse } from "../api/types.js";
/** Repository the released artifacts come from (same slug as cli/update.ts's REPO_SLUG). */
const REPO_SLUG = "Prism-Shadow/penguin-harness";
/** Releases API endpoint for the newest published release. */
export const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`;
/** How long a successful lookup is served from cache. */
export const SUCCESS_TTL_MS = 60 * 60 * 1000;
/** How long a failed lookup is served from cache (short: transient failures should heal). */
export const FAILURE_TTL_MS = 10 * 60 * 1000;
export interface UpdateCheckServiceOptions {
/** Test double for the network call (defaults to global fetch). */
fetchImpl?: typeof fetch;
/** Injectable clock for TTL determinism in tests. */
now?: () => Date;
/** Environment to read PENGUIN_UPDATE_CHECK from (defaults to process.env). */
env?: Record<string, string | undefined>;
}
export class UpdateCheckService {
private readonly fetchImpl: typeof fetch;
private readonly now: () => Date;
private readonly env: Record<string, string | undefined>;
private cached: { response: UpdateCheckResponse; expiresAt: number } | null = null;
private inflight: Promise<UpdateCheckResponse> | null = null;
constructor(options: UpdateCheckServiceOptions = {}) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.now = options.now ?? (() => new Date());
this.env = options.env ?? process.env;
}
/**
* Never throws; never makes a network call when disabled or (without `force`) while
* the cache is fresh. `force` — the web's manual "check for updates" action — skips
* the freshness check and performs a real lookup even over a warm cache: the cache
* exists to shield GitHub's unauthenticated rate limit from *passive* checks fired
* by every sidebar open, and an explicit user click is rare enough to press through
* it. Everything else is unchanged: the opt-out stays authoritative (force never
* dials out under PENGUIN_UPDATE_CHECK=off), the outcome lands in the same cache
* (subsequent passive checks reuse it), and concurrent callers — forced or not —
* still share one in-flight lookup.
*/
async check(force = false): Promise<UpdateCheckResponse> {
if (this.env["PENGUIN_UPDATE_CHECK"] === "off") {
return {
currentVersion: VERSION,
buildDate: BUILD_DATE,
latestVersion: null,
updateAvailable: false,
releaseUrl: null,
publishedAt: null,
checkedAt: this.now().toISOString(),
disabled: true,
};
}
if (!force && this.cached && this.now().getTime() < this.cached.expiresAt) {
return this.cached.response;
}
this.inflight ??= this.lookup().finally(() => {
this.inflight = null;
});
return this.inflight;
}
/** One real lookup; stores the outcome (success or failure) in the cache with its TTL. */
private async lookup(): Promise<UpdateCheckResponse> {
const response = await this.resolveLatest();
const ttl = response.error === undefined ? SUCCESS_TTL_MS : FAILURE_TTL_MS;
this.cached = { response, expiresAt: this.now().getTime() + ttl };
return response;
}
/**
* Failure taxonomy mirrors the CLI's fetchLatestVersion: an unreachable endpoint or
* timeout is "network"; a 403/429 is "rate_limited" (unauthenticated clients hit
* GitHub's per-IP limit); any other non-2xx status, an unparsable body, or a body
* without a usable tag_name is "bad_response".
*/
private async resolveLatest(): Promise<UpdateCheckResponse> {
const checkedAt = this.now().toISOString();
const base = { currentVersion: VERSION, buildDate: BUILD_DATE, checkedAt };
const failure = (error: "network" | "rate_limited" | "bad_response"): UpdateCheckResponse => ({
...base,
latestVersion: null,
updateAvailable: false,
releaseUrl: null,
publishedAt: null,
error,
});
let res: Response;
try {
res = await this.fetchImpl(LATEST_RELEASE_API, {
headers: { accept: "application/vnd.github+json", "user-agent": "penguin-server" },
signal: AbortSignal.timeout(15_000),
});
} catch {
return failure("network");
}
if (res.status === 403 || res.status === 429) return failure("rate_limited");
if (!res.ok) return failure("bad_response");
let body: { tag_name?: unknown; html_url?: unknown; published_at?: unknown };
try {
body = (await res.json()) as typeof body;
} catch {
return failure("bad_response");
}
const tag = typeof body.tag_name === "string" ? normalizeVersion(body.tag_name) : "";
if (tag === "") return failure("bad_response");
return {
...base,
latestVersion: tag,
updateAvailable: compareVersions(tag, VERSION) > 0,
releaseUrl: typeof body.html_url === "string" ? body.html_url : null,
publishedAt: typeof body.published_at === "string" ? body.published_at : null,
};
}
}