From 3be152e6c179a46a7abfa2d7a46d96f1894b4a6a Mon Sep 17 00:00:00 2001 From: Mahmudur Rahman Labib Date: Tue, 20 Oct 2026 19:34:34 +0600 Subject: [PATCH 1/2] Chat page branding, uptime on /, fresh runtime icons, log pool create errors - AgentWebUI Lite keeps its own name (BRAND_NAME=AgentWebUI, not the agent's name), shows the Atlas Agents mark (BRAND_LOGO, the dashboard's white logo over https), and calls the signed-in user Admin (USER_NAME; its sidebar said "You"). Reaches running boxes on their next reprovision. - Uptime Kuma probes the agent's root (/): a chat-UI agent answers /health with 401, so every one read as down. `npm run uptime:resync` re-points the existing monitors (dry run by default). - Runtime icon URLs carry a version (?v=2): the CDN kept serving the old OpenCode and Pi placeholders for hours under the same URL. - The warm-pool scheduler now logs the create / advance / reap errors it collected; a create that threw only fed the breaker, so the logs said "3 bake failure(s)" and never why. Co-Authored-By: Claude --- package.json | 1 + scripts/resync-uptime-monitors.ts | 43 +++++++++++++++++++++++ src/components/dashboard/runtime-logo.tsx | 7 +++- src/lib/runtimes/agentwebui-lite.test.ts | 18 ++++++++++ src/lib/runtimes/agentwebui-lite.ts | 24 ++++++++++++- src/lib/uptime-monitoring.ts | 20 ++++++----- src/lib/warm-pool.ts | 16 ++++++++- 7 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 scripts/resync-uptime-monitors.ts diff --git a/package.json b/package.json index c2a5280..647213a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "backfill:personal-teams": "node --env-file=.env --import tsx scripts/backfill-personal-teams.ts", "wallets:migrate": "node --env-file=.env --import tsx scripts/migrate-wallets-to-credits.ts", "chat-ui:migrate": "node --env-file=.env --import tsx scripts/migrate-chat-ui-to-lite.ts", + "uptime:resync": "node --env-file=.env --import tsx scripts/resync-uptime-monitors.ts", "whitelabel:backfill-hostnames": "node --env-file=.env --import tsx scripts/backfill-custom-hostnames.ts", "byos:sweep-orphans": "node --env-file=.env --import tsx scripts/sweep-orphan-byos-instances.ts", "seed:agent-events": "node --env-file=.env --import tsx scripts/seed-agent-events.ts", diff --git a/scripts/resync-uptime-monitors.ts b/scripts/resync-uptime-monitors.ts new file mode 100644 index 0000000..715d5c2 --- /dev/null +++ b/scripts/resync-uptime-monitors.ts @@ -0,0 +1,43 @@ +// Point every agent's Uptime Kuma monitor at what it should probe now: the +// agent's public root (`/`), where a 200 means up. Monitors made before probed +// the runtime's health path, which a chat-UI agent answers with 401, so they +// all read as down. New and changed agents are synced on their own +// (syncUptimeMonitor); this fixes the monitors that already exist. +// +// Usage: +// node --env-file=.env --import tsx scripts/resync-uptime-monitors.ts # dry run: list only +// node --env-file=.env --import tsx scripts/resync-uptime-monitors.ts --apply +// +// DEFAULTS TO DRY RUN, which only reads the database. + +import { db, instances, and, ne, isNotNull } from "@/db" +import { buildMonitorUrl, syncUptimeMonitor } from "@/lib/uptime-monitoring" + +const APPLY = process.argv.includes("--apply") + +async function main() { + const rows = await db.query.instances.findMany({ + where: and(isNotNull(instances.kumaMonitorId), ne(instances.status, "deleted")), + }) + console.log(`${rows.length} agent(s) with a monitor${APPLY ? "" : " (dry run; pass --apply to update)"}`) + let failed = 0 + for (const row of rows) { + const url = buildMonitorUrl(row) + console.log(` ${row.id} ${row.name} monitor #${row.kumaMonitorId} → ${url ?? "(not reachable; skipped)"}`) + if (!APPLY || !url) continue + try { + await syncUptimeMonitor(row.id) + } catch (err) { + failed++ + console.error(` FAILED: ${err instanceof Error ? err.message : String(err)}`) + } + } + console.log(APPLY ? `Done. ${failed} failed (see the agent's log for monitor errors).` : "Dry run only.") +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err) + process.exit(1) + }) diff --git a/src/components/dashboard/runtime-logo.tsx b/src/components/dashboard/runtime-logo.tsx index bc12961..b3059ef 100644 --- a/src/components/dashboard/runtime-logo.tsx +++ b/src/components/dashboard/runtime-logo.tsx @@ -10,6 +10,11 @@ import { cn } from "@/lib/utils" // Every file listed here is a white glyph (public/runtimes/*.svg). const MONOCHROME_RUNTIMES = new Set(["hermes", "opencode", "pi", "grok"]) +// Bumped whenever a file in public/runtimes/ is replaced. The CDN caches those +// files for hours under their old content, so a changed icon needs a new URL +// (2: OpenCode and Pi's real marks replaced the placeholders). +const LOGO_VERSION = 2 + export function RuntimeLogo({ runtime, size, @@ -27,7 +32,7 @@ export function RuntimeLogo({ const img = ( // eslint-disable-next-line @next/next/no-img-element {alt} { }) }) +describe("Lite's branding", () => { + it("keeps the AgentWebUI name, calls the signed-in user Admin, and shows our logo", () => { + const env = getRuntime("grok").sidecar!.renderEnv(envArgs()) + expect(env.BRAND_NAME).toBe(LITE_BRAND_NAME) + expect(LITE_BRAND_NAME).toBe("AgentWebUI") + expect(env.USER_NAME).toBe(LITE_USER_NAME) + expect(LITE_USER_NAME).toBe("Admin") + }) + + it("points the logo at the dashboard's public mark, and only over https", () => { + expect(liteBrandLogo("https://app.atlasagents.dev/")).toBe("https://app.atlasagents.dev/assets/icons/SovereignML-logo-white.svg") + expect(liteBrandLogo("http://localhost:3000")).toBeUndefined() + }) +}) + describe("installing Lite", () => { it("installs Hermes with its ACP extra and Lite, and no Open WebUI", () => { const script = getRuntime("hermes").renderBootstrap({ diff --git a/src/lib/runtimes/agentwebui-lite.ts b/src/lib/runtimes/agentwebui-lite.ts index c1f1482..bf83321 100644 --- a/src/lib/runtimes/agentwebui-lite.ts +++ b/src/lib/runtimes/agentwebui-lite.ts @@ -1,3 +1,4 @@ +import { BRANDING } from "@/configs/branding" import { isPinned, npmSpec, pypiSpec } from "@/lib/runtime-versions" import { APT_OPTS_LITERAL } from "./bootstrap" import { bashSingleQuote, buildPyInstallPrelude, buildVerifyPythonStep } from "./python-install" @@ -250,8 +251,13 @@ export function makeLiteSidecar(engine: RuntimeEngine): SidecarSpec { // command and exits at start. Lite's default (10 minutes) applies. [`${key}_COMMAND`]: acpCommand(engine), [`${key}_NAME`]: name, - BRAND_NAME: name.slice(0, 40), + // The page keeps its own name, carries our logo, and calls the one + // signed-in user Admin (Lite's sidebar says "You" without a name). + BRAND_NAME: LITE_BRAND_NAME, + USER_NAME: LITE_USER_NAME, } + const logo = liteBrandLogo() + if (logo) env.BRAND_LOGO = logo if (args.selectableModels?.length) { if (model) env[`${key}_MODEL`] = liteModelId(engine.id, model) env[`${key}_MODELS`] = models.map((s) => liteModelId(engine.id, s)).join(",") @@ -294,6 +300,22 @@ WantedBy=multi-user.target } } +/** The chat page's name, as Lite ships it. */ +export const LITE_BRAND_NAME = "AgentWebUI" +/** What the chat page calls its one signed-in user. */ +export const LITE_USER_NAME = "Admin" + +/** + * The Atlas Agents mark for the chat page's header, served by the dashboard + * (the white mark: Lite opens in its dark theme). Lite fetches it in the + * browser, so it must be a public https URL; absent otherwise (dev), and Lite + * shows its built-in logo. + */ +export function liteBrandLogo(appUrl: string = BRANDING.appUrl): string | undefined { + if (!/^https:\/\//.test(appUrl)) return undefined + return `${appUrl.replace(/\/+$/, "")}/assets/icons/SovereignML-logo-white.svg` +} + /** * Bash that removes Open WebUI from a box: its unit, its whole install under * /opt/open-webui (venv, data and chat history), its env file next to any diff --git a/src/lib/uptime-monitoring.ts b/src/lib/uptime-monitoring.ts index f2cfed0..649cc9a 100644 --- a/src/lib/uptime-monitoring.ts +++ b/src/lib/uptime-monitoring.ts @@ -17,7 +17,6 @@ import type { InferSelectModel } from "drizzle-orm" import { db, instances, instanceUptimeSamples, eq, and, isNotNull, isNull, lt, sql } from "@/db" import { buildPublicGatewayUrl } from "@/lib/instance-gateway-access" -import { getRuntime, type RuntimeEngine } from "@/lib/runtimes" import { logInstanceEvent } from "@/lib/instance-state" import { getKumaEnv, @@ -32,11 +31,16 @@ import { type Instance = InferSelectModel /** - * The URL a Kuma HTTP monitor should probe: the agent's public gateway plus the - * runtime's health path. Returns null when the agent isn't publicly reachable - * yet (no domain / subdomain / IP) — caller no-ops. + * The URL a Kuma HTTP monitor should probe: the agent's public root, where a + * 200 means it is up. Not the runtime's health path: on an agent with a chat + * UI, Caddy sends everything but `/v1/*` to the chat page, which answers its + * `/health` with 401 (sign-in required), so every such agent read as down. + * Returns null when the agent isn't publicly reachable yet (no domain / + * subdomain / IP) — caller no-ops. */ -function buildMonitorUrl(instance: Instance, runtime: RuntimeEngine): string | null { +export function buildMonitorUrl( + instance: Pick, +): string | null { const base = buildPublicGatewayUrl({ domain: instance.domain, managedSubdomain: instance.managedSubdomain, @@ -53,8 +57,7 @@ function buildMonitorUrl(instance: Instance, runtime: RuntimeEngine): string | n origin = `http://${instance.ipAddress}:${instance.containerPort}` } - const path = runtime.healthPath || "/" - return origin.replace(/\/+$/, "") + (path.startsWith("/") ? path : `/${path}`) + return origin.replace(/\/+$/, "") + "/" } /** @@ -75,8 +78,7 @@ export async function syncUptimeMonitor(instanceId: string): Promise { } if (!instance) return - const runtime = getRuntime(instance.runtime) - const url = buildMonitorUrl(instance, runtime) + const url = buildMonitorUrl(instance) if (!url) return // not publicly reachable yet — nothing to monitor const name = `${instance.name} (${instance.id})` diff --git a/src/lib/warm-pool.ts b/src/lib/warm-pool.ts index 36ea57b..c18990e 100644 --- a/src/lib/warm-pool.ts +++ b/src/lib/warm-pool.ts @@ -928,6 +928,19 @@ let refillInFlight: Promise | null = null * already running (single in-flight promise, process-wide). Cross-process * safety comes from the advisory lock in `refillWarmPoolLocked`. */ +/** + * Print the errors a scheduled refill collected that nothing else logs: a + * create that throws (provider API refusal, DNS, a bootstrap that cannot be + * rendered), an advance or a reap. Before this they only fed the breaker, so + * the logs said "3 bake failure(s)" and never why. Bake failures and breaker + * pauses are logged where they happen. + */ +function logRefillErrors(report: WarmPoolReport): void { + for (const e of report.errors) { + if (/^(create|advance|reap) /.test(e)) console.warn(`[warm-pool] ${e}`) + } +} + export async function maybeRefillWarmPool(): Promise { if (!isPoolEnabled()) return if (refillInFlight) { @@ -937,7 +950,8 @@ export async function maybeRefillWarmPool(): Promise { if (Date.now() - lastRefillAt < REFILL_MIN_INTERVAL_MS) return lastRefillAt = Date.now() refillInFlight = refillWarmPoolLocked() - .catch(() => {}) + .then(logRefillErrors) + .catch((err) => console.error("[warm-pool] refill failed:", err)) .finally(() => { refillInFlight = null }) From 011dc6c4c570a551b637759393825043455aa8a3 Mon Sep 17 00:00:00 2001 From: Mahmudur Rahman Labib Date: Tue, 20 Oct 2026 19:40:35 +0600 Subject: [PATCH 2/2] Warm pool on Linode: gzip user-data over 16 KB; show Linode's error reason Every universal pool create on Linode failed with a bare "Request failed with status code 400", and the breaker paused the pool. Linode's reason, checked on prod: "metadata.user_data: decoded user_data must not exceed 16384 bytes". The universal image's bootstrap is 38 KB (it was ~30 KB before Gemini CLI and Grok, and failed the same way). Single-engine bootstraps are 11-14 KB and fit. - linodeUserData() sends a script over 16 KB gzip-compressed (the universal image: 7.4 KB). cloud-init detects gzip user-data and decompresses it. Smaller scripts go uncompressed, as before. - createVM rethrows with describeLinodeError, so the reason reaches the logs and the pool's error report (never the request config, which has the token). Co-Authored-By: Claude --- docs/RUNTIMES.md | 2 +- src/lib/linode-user-data.test.ts | 22 ++++++++++++++++++++++ src/lib/linode-user-data.ts | 23 +++++++++++++++++++++++ src/lib/linode.ts | 16 +++++++++++----- 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/lib/linode-user-data.test.ts create mode 100644 src/lib/linode-user-data.ts diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index ef69955..14ac9de 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -197,7 +197,7 @@ The chat UI of every agent but OpenClaw. It replaced Open WebUI to make installs ### Warm pool: the universal image ([`universal.ts`](../src/lib/runtimes/universal.ts)) -Every engine the chat UI fronts shares one warm pool. A **universal** pool VM bakes every engine but OpenClaw (Hermes, Claude Code, Codex, OpenCode, Pi, Gemini CLI and Grok), each at its pin, plus Lite and every engine's ACP side, inside one bootstrap framing (`buildPrereqScript`, every engine's system account; about 38 KB of user-data, under DigitalOcean's 64 KB cap). Its finalize runs only Hermes' `setup --quick` / `doctor --fix` warm-up and enables no unit. A deploy of any of the seven (with its chat UI) claims a universal VM (`poolRuntimeFor` in [`warm-pool-key.ts`](../src/lib/warm-pool-key.ts)); the claim writes and starts only the chosen engine's env, config and units (Phase A / B), so the other engines stay installed with no units, env or keys. The pool key folds in every engine's version and Lite's, so a bump to any pin retires the pool. OpenClaw keeps its own pool. Sizes: `POOL_SIZE_UNIVERSAL` (default 2; `POOL_SIZE_HERMES` still read), `POOL_SIZE_OPENCLAW`. +Every engine the chat UI fronts shares one warm pool. A **universal** pool VM bakes every engine but OpenClaw (Hermes, Claude Code, Codex, OpenCode, Pi, Gemini CLI and Grok), each at its pin, plus Lite and every engine's ACP side, inside one bootstrap framing (`buildPrereqScript`, every engine's system account; about 38 KB of user-data: under DigitalOcean's 64 KB cap, but over **Linode's 16 KB** ("decoded user_data must not exceed 16384 bytes"), so on Linode a script over 16 KB is sent gzip-compressed (`linodeUserData` in [`linode-user-data.ts`](../src/lib/linode-user-data.ts); about 7.4 KB) and cloud-init decompresses it. Before that, every universal create on Linode was refused with a 400, and the breaker paused the pool). Its finalize runs only Hermes' `setup --quick` / `doctor --fix` warm-up and enables no unit. A deploy of any of the seven (with its chat UI) claims a universal VM (`poolRuntimeFor` in [`warm-pool-key.ts`](../src/lib/warm-pool-key.ts)); the claim writes and starts only the chosen engine's env, config and units (Phase A / B), so the other engines stay installed with no units, env or keys. The pool key folds in every engine's version and Lite's, so a bump to any pin retires the pool. OpenClaw keeps its own pool. Sizes: `POOL_SIZE_UNIVERSAL` (default 2; `POOL_SIZE_HERMES` still read), `POOL_SIZE_OPENCLAW`. The deploy's completion log says how long it took and whether it came from the pool (`Deployment complete in 38s (warm pool)`); a warm claim is what keeps a deploy under a minute, and a cold install cannot. The start phase fails a service that crashes at start at once, with its journal, instead of retrying it. diff --git a/src/lib/linode-user-data.test.ts b/src/lib/linode-user-data.test.ts new file mode 100644 index 0000000..75dedfb --- /dev/null +++ b/src/lib/linode-user-data.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { gunzipSync } from "node:zlib" +import { LINODE_USER_DATA_MAX_BYTES, linodeUserData } from "./linode-user-data" +import { renderUniversalBootstrap } from "./runtimes/universal" + +/** Linode refuses user-data over 16 KB once decoded; cloud-init unpacks gzip user-data itself. */ +describe("linodeUserData", () => { + it("sends a script that fits as plain base64, as before", () => { + const script = "#!/bin/bash\necho hi\n" + expect(Buffer.from(linodeUserData(script), "base64").toString("utf8")).toBe(script) + }) + + it("gzips the universal warm-pool image, which is over the limit, back to the exact script", () => { + const script = renderUniversalBootstrap() + expect(Buffer.byteLength(script)).toBeGreaterThan(LINODE_USER_DATA_MAX_BYTES) + const sent = Buffer.from(linodeUserData(script), "base64") + expect(sent.length).toBeLessThanOrEqual(LINODE_USER_DATA_MAX_BYTES) + expect(sent[0]).toBe(0x1f) // gzip magic, which cloud-init detects + expect(sent[1]).toBe(0x8b) + expect(gunzipSync(sent).toString("utf8")).toBe(script) + }) +}) diff --git a/src/lib/linode-user-data.ts b/src/lib/linode-user-data.ts new file mode 100644 index 0000000..b2ab337 --- /dev/null +++ b/src/lib/linode-user-data.ts @@ -0,0 +1,23 @@ +import { gzipSync } from "node:zlib" + +// Pure (no DB, no network): linode.ts uses it, and the tests import it. + +/** Linode refuses user-data larger than this once decoded (its API's own limit). */ +export const LINODE_USER_DATA_MAX_BYTES = 16384 + +/** + * The `metadata.user_data` for a bootstrap script: base64 of the script, or of + * the script gzip-compressed when it is over Linode's 16 KB limit (the + * universal warm-pool image is ~38 KB). cloud-init recognises gzip user-data + * and decompresses it before running the script, so the box sees the same + * bytes either way. Small scripts go as before, uncompressed. + */ +export function linodeUserData(script: string): string { + const raw = Buffer.from(script, "utf8") + if (raw.length <= LINODE_USER_DATA_MAX_BYTES) return raw.toString("base64") + const gz = gzipSync(raw, { level: 9 }) + if (gz.length > LINODE_USER_DATA_MAX_BYTES) { + throw new Error(`bootstrap is ${gz.length} bytes gzip-compressed, over Linode's ${LINODE_USER_DATA_MAX_BYTES}-byte user-data limit`) + } + return gz.toString("base64") +} diff --git a/src/lib/linode.ts b/src/lib/linode.ts index 4eaeba2..cdc26d3 100644 --- a/src/lib/linode.ts +++ b/src/lib/linode.ts @@ -12,6 +12,7 @@ import { setToken, } from "@linode/api-v4" import type { Linode, CreateLinodeRequest } from "@linode/api-v4/lib/linodes" +import { linodeUserData } from "@/lib/linode-user-data" import type { Image } from "@linode/api-v4/lib/images" import type { VpsProvider, @@ -98,12 +99,17 @@ export class LinodeProvider implements VpsProvider { image: opts.image ?? "linode/ubuntu24.04", authorized_keys: opts.authorizedKeys, tags: opts.tags, - ...(opts.userData - ? { metadata: { user_data: Buffer.from(opts.userData, "utf8").toString("base64") } } - : {}), + ...(opts.userData ? { metadata: { user_data: linodeUserData(opts.userData) } } : {}), } as CreateLinodeRequest - const vm = await createLinode(payload) - return linodeToVmInfo(vm) + try { + const vm = await createLinode(payload) + return linodeToVmInfo(vm) + } catch (err) { + // Linode's own reason (e.g. "metadata.user_data: decoded user_data must + // not exceed 16384 bytes"), not axios' "Request failed with status code + // 400" — and never the request config, which carries our token. + throw new Error(describeLinodeError(err)) + } } async deleteVM(id: string): Promise {