Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/RUNTIMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions scripts/resync-uptime-monitors.ts
Original file line number Diff line number Diff line change
@@ -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)
})
7 changes: 6 additions & 1 deletion src/components/dashboard/runtime-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,7 +32,7 @@ export function RuntimeLogo({
const img = (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/runtimes/${runtimeId}.svg`}
src={`/runtimes/${runtimeId}.svg?v=${LOGO_VERSION}`}
alt={alt}
width={size}
height={size}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/linode-user-data.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
23 changes: 23 additions & 0 deletions src/lib/linode-user-data.ts
Original file line number Diff line number Diff line change
@@ -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")
}
16 changes: 11 additions & 5 deletions src/lib/linode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/runtimes/agentwebui-lite.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, it } from "vitest"
import {
LITE_BRAND_NAME,
LITE_USER_NAME,
liteBrandLogo,
ACP_ADAPTERS,
LEGACY_OPEN_WEBUI_SERVICE,
LITE_MIGRATED,
Expand Down Expand Up @@ -147,6 +150,21 @@ describe("Lite as the sidecar", () => {
})
})

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({
Expand Down
24 changes: 23 additions & 1 deletion src/lib/runtimes/agentwebui-lite.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(",")
Expand Down Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions src/lib/uptime-monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,11 +31,16 @@ import {
type Instance = InferSelectModel<typeof instances>

/**
* 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<Instance, "domain" | "managedSubdomain" | "tlsStatus" | "ipAddress" | "containerPort">,
): string | null {
const base = buildPublicGatewayUrl({
domain: instance.domain,
managedSubdomain: instance.managedSubdomain,
Expand All @@ -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(/\/+$/, "") + "/"
}

/**
Expand All @@ -75,8 +78,7 @@ export async function syncUptimeMonitor(instanceId: string): Promise<void> {
}
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})`
Expand Down
16 changes: 15 additions & 1 deletion src/lib/warm-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,19 @@ let refillInFlight: Promise<unknown> | 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<void> {
if (!isPoolEnabled()) return
if (refillInFlight) {
Expand All @@ -937,7 +950,8 @@ export async function maybeRefillWarmPool(): Promise<void> {
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
})
Expand Down
Loading