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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user