Compare commits

...
3 Commits
Author SHA1 Message Date
zooqueen ba571999a2 the Discord invite on the deployed console is dead — recover the fix
Hanzo CI/CD / cicd (push) Successful in 3m13s
CI/CD / cicd (push) Successful in 3m13s
Four places pointed at discord.gg/hanzo. Discord's API answers "Unknown
Invite" for that code, so every Community / Join Discord control on the
deployed console led nowhere. discord.gg/CJCyAsm9Vr resolves to the real Hanzo
guild; verified both against https://discord.com/api/v10/invites/<code> rather
than by following the redirect, which returns 301 for a dead invite exactly as
it does for a live one.

Recovered from origin/main 03ed5663 ("Point every Discord invite at the current
Hanzo server"), stranded by the two-lineage split like the static image before
it — the fix existed and had simply never reached the branch that ships.

Also takes bff3be33's comment rename, EVENT_INGEST_KEY -> PUBLISHABLE_KEY: one
line, and it is the "do not fix this by reading NEXT_PUBLIC_…" warning, so a
stale variable name there points the next reader at an env var that no longer
exists.

The third stranded commit (10be3234, "topbar: the theme toggle comes from the
package, not a deleted file") is deliberately NOT taken. This lineage solved it
differently and better — the toggle left the topbar entirely and lives in the
account menu, so there is no import to repoint and no local ThemeToggle to
delete. Stranded is not the same as missing.
2026-08-05 21:18:25 -07:00
zooqueen 594314d351 console: the servable static image, recovered from the other lineage
Hanzo CI/CD / cicd (push) Successful in 3m8s
CI/CD / cicd (push) Successful in 3m9s
Cherry-picked in substance from origin/main 72870474 ("a servable static
image, so a frontend change needs no cloud release", 2026-08-04) and a4df740e
("one image, and the console is what it is", 2026-08-05), by hanzo-dev. Neither
had ever reached the DEPLOYED lineage, because this repo carries two histories
with NO common ancestor — 1643 commits on forge, 1549 on origin, and no merge
base at all.

The work was stranded, not superseded, and it is worth recovering because it
fixes something that cost two production outages TODAY. console.hanzo.ai is
answered by the cloud binary, which go:embeds the bundle, so every console
change needs a cloud image, a universe pin and a cloud roll — and cloud runs
`strategy: Recreate`, which makes each one a ~2-minute total API outage. Two
console fixes this afternoon cost exactly that, twice.

Dockerfile now builds the SPA export behind hanzoai/static and serves itself on
3000 — which is precisely the port the standalone `console` Deployment already
declares and has been waiting for. Dockerfile.embed is BYTE-IDENTICAL between
the two lineages and is untouched here, so the path that currently serves
console.hanzo.ai does not move. This adds a capability; it changes no route.

Flipping console.hanzo.ai from `service: cloud` to `service: console` in
universe hanzo-domains.yaml is the follow-up that actually collects the benefit,
and it is deliberately NOT in this commit: it changes how the console is served
and belongs to a decision, not to a file recovery.

Reconciling by SUBJECT rather than by hash is what made this tractable — 875 of
~890 subjects are shared, so the split is a re-import, not divergent work. Only
8 subjects exist solely on origin and 20 solely on forge, and of origin's 8, two
are git stash entries and one is the same fix carrying a "(#1)" PR suffix.
2026-08-05 21:16:35 -07:00
zooqueen c709b1f96a playground: every example suggests a model the gateway serves
Hanzo CI/CD / cicd (push) Successful in 4m55s
CI/CD / cicd (push) Successful in 5m2s
All six starters chipped `zen-omni` or `zen-coder`. The gateway carries neither
and never did — zen's text models are `zen5`, `zen5-mini`, `zen5-flash`,
`zen5-coder`, `zen5-pro`, while `zen-<noun>` names a modality (embedding, image,
video, rerank, voice, vl, guard).

Nothing threw, which is why it survived: applying a starter falls back to the
currently-selected model when the suggestion is absent from the live catalog
(`models.byId.has(...)`), so the card simply advertised a model that could never
be the one that ran. The model-id placeholder gave the same dead example.

`examples.test.ts` tests the SHAPE, not a list of ids — a hardcoded catalog would
rot exactly the way the suggestions did — so a modality SKU or a retired id
cannot come back. Same defect the agent builder's `defaultModel` had, where the
consequence was worse: it silently selected `zen-embedding`.
2026-08-05 19:41:42 -07:00
9 changed files with 138 additions and 83 deletions
+50 -47
View File
@@ -1,48 +1,51 @@
# console2Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
ARG SOURCE_COMMIT=""
ENV SOURCE_COMMIT=$SOURCE_COMMIT
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
# created node_modules (the 'next not found' cause — the install's own `test -f next`
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
# before install means node_modules is created by the LAST RUNs and nothing clobbers
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
COPY . .
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
RUN mkdir -p public
# corepack installs the exact pnpm from package.json's `packageManager`, so the
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
# across npm versions and a lockfile written by one npm failed under another. pnpm
# records every platform in the lockfile, so the build installs exactly what is
# committed and fails loudly instead of quietly resolving something else.
RUN corepack enable && pnpm install --frozen-lockfile
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
# (exit 137); cap the heap generously (chat uses 4096).
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
RUN pnpm build
# hanzoai/console — the console image. It serves itself.
#
# The console is a static SPA export; this puts hanzoai/static in front of it. That
# itself: hanzoai/static in front of the bundle. It exists so a console change
# can reach production without a cloud release.
#
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
# to a backend release: the bundle must be published, its tag pinned in cloud's
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
# preceded this one says what that costs — "four changes that could not reach
# production".
#
# Nothing about the request path changes when this serves instead. The embedded
# console is already a static export talking to the SAME origin's /v1, and cloud's
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
# everything else here. Same bytes, same origin, same cookie — one fewer release
# in the way.
#
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
# the opposite setting for the opposite reason — there a miss is a mistake.
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.mjs ./next.config.mjs
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
USER app
EXPOSE 4000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
FROM public.ecr.aws/docker/library/node:24-alpine AS build
RUN apk add --no-cache git
WORKDIR /console
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
# the same default Dockerfile.embed bakes, so a bundle served from here reports
# identically to one served from inside cloud.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
# empty index.html would serve a blank page on every route with a 200, which is
# indistinguishable from a working deploy until someone opens it.
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
# cannot change under a rebuild. (The console's OWN release is named by semver in
# the values file — that is the version a human reads.)
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
COPY --from=build /console/out/ /srv/
EXPOSE 3000
ENTRYPOINT ["/static"]
CMD ["-root=/srv", "-spa", "-port=3000"]
+30 -22
View File
@@ -1,34 +1,42 @@
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
#
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
# automatically.
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
# only who serves it:
#
# BOTH console images are declared here — one config, read by whichever runner
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
# could not run: hanzoai/console had the forge Actions unit DISABLED
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
# both images here puts them on the ONE pipeline, wherever it executes.
# console-embed the bundle alone at /dist. hanzoai/cloud does
# `COPY --from=console /dist/` so a cloud release never rebuilds
# npm+Next. Needed only while cloud go:embeds the console.
# console the bundle behind hanzoai/static, serving itself. This is how
# a console change ships WITHOUT a cloud release: move image.tag
# in a universe values file and cd rolls it.
#
# Tag shape changes with the builder, deliberately: the shared builder publishes
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
# The Next.js SERVER image that used to be the second entry is gone. It was
# already doing nothing a file server could not — every host it served sent /v1
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
# auth routes stopped mattering when identity became a client-held IAM token.
#
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
# deployment carries, which a sha cannot. The discipline that keeps that honest is
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
# instead.
images:
- name: console-embed
context: .
dockerfile: Dockerfile.embed
repo: ghcr.io/hanzoai/console-embed
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
# one brand). SOURCE_COMMIT is the only build arg it ever took.
# The console. It is static — that is not a variant, it is what the console IS,
# so the image is `console` and there is no adjective in the name. Dockerfile
# builds the SPA export and puts hanzoai/static in front of it.
#
# This REPLACES the Next.js server image that used to be published here. It was
# already doing nothing a file server could not: every host it serves
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
# of them. Its own auth routes went the same way when identity became a
# client-held IAM token. One console, one image.
- name: console
context: .
dockerfile: Dockerfile
+2 -2
View File
@@ -59,8 +59,8 @@ const CHANNELS: Channel[] = [
icon: MessageCircle,
title: 'Join Our Discord',
body: 'Chat live with developers and other users from our community.',
href: 'https://discord.gg/hanzo',
cta: 'discord.gg/hanzo',
href: 'https://discord.gg/CJCyAsm9Vr',
cta: 'discord.gg/CJCyAsm9Vr',
},
{
icon: Linkedin,
@@ -107,7 +107,7 @@ function NeedHelpCard() {
return (
<SectionCard title="Need help?" p="$3">
<YStack gap="$0.5">
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" onPress={() => openExternal(`mailto:support@${apex}`)} />
</YStack>
</SectionCard>
@@ -54,7 +54,7 @@ export function ResourceRail({ config, onViewCode }: { config: ProductLandingCon
<LandingCard title="Need help?" p="$3">
<YStack gap="$0.5">
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
</YStack>
</LandingCard>
@@ -12,7 +12,7 @@ export function ModelSelect({
ids,
onChange,
disabled,
placeholder = 'model id, e.g. zen-omni',
placeholder = 'model id, e.g. zen5-mini',
}: {
value: string
ids: string[]
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { EXAMPLES } from './examples'
describe('EXAMPLES', () => {
it('has unique ids and fills every field', () => {
const ids = EXAMPLES.map((e) => e.id)
expect(new Set(ids).size).toBe(ids.length)
for (const e of EXAMPLES) {
expect(e.label.trim()).not.toBe('')
expect(e.system.trim()).not.toBe('')
expect(e.user.trim()).not.toBe('')
}
})
/**
* The defect this guards: all six examples once suggested `zen-omni` or `zen-coder`,
* and the gateway serves neither. Applying an example falls back to the selected
* model when the suggestion is absent, so nothing threw and nothing was logged — the
* card just advertised a model that could never be the one that ran.
*
* The test is on the SHAPE, not on a list of ids, because a hardcoded catalog would
* rot the same way the suggestions did. Zen's naming splits cleanly: `zen5…` are the
* text models; `zen-<noun>` names a modality (embedding, image, video, rerank, voice,
* vl, guard) and cannot hold a chat turn. A chat example must suggest a text model.
*/
it('suggests only Zen TEXT models — never a modality SKU or a retired id', () => {
for (const e of EXAMPLES) {
expect(e.model, `${e.id} suggests "${e.model}"`).toMatch(/^zen\d/)
}
})
it('never suggests the ids that were wrong', () => {
const retired = new Set(['zen-omni', 'zen-coder'])
for (const e of EXAMPLES) expect(retired.has(e.model)).toBe(false)
})
})
+15 -8
View File
@@ -3,8 +3,15 @@
*
* Each is a clearly-labelled STARTER (not fabricated history): picking one fills
* the System + User message and, when the suggested model is in the live catalog,
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
* exactly like the mockup ("Explain quantum computing · zen-omni").
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip.
*
* EVERY MODEL HERE MUST BE ONE THE GATEWAY SERVES. All six once named `zen-omni` or
* `zen-coder`, and the catalog carries neither — zen's text models are `zen5…`, while
* `zen-<noun>` names a modality (embedding, image, video, rerank, voice, vl). Applying
* an example falls back to the currently-selected model when the suggestion is absent
* (`models.byId.has(...)`), so nothing broke and nothing was logged: the card simply
* advertised a model that would never be the one that ran. `examples.test.ts` bites on
* the shape now, so a modality SKU or a retired id cannot come back.
*/
export type Example = {
id: string
@@ -20,42 +27,42 @@ export const EXAMPLES: Example[] = [
{
id: 'quantum',
label: 'Explain quantum computing',
model: 'zen-omni',
model: 'zen5-mini',
system: 'You are a patient teacher. Explain clearly for a curious beginner.',
user: 'Explain quantum computing in simple terms, with one everyday analogy.',
},
{
id: 'debounce',
label: 'Write a debounce function',
model: 'zen-coder',
model: 'zen5-coder',
system: 'You are an expert TypeScript engineer. Return only the code, no prose.',
user: 'Write a typed debounce<T> function with a cancel() method.',
},
{
id: 'summarize',
label: 'Summarize a paragraph',
model: 'zen-omni',
model: 'zen5-mini',
system: 'Summarize the user text in exactly three bullet points.',
user: 'Hanzo Cloud is a unified AI gateway exposing hundreds of models behind one OpenAI-compatible API, with built-in retrieval, billing and per-org keys, so orgs switch models without changing code.',
},
{
id: 'json',
label: 'Extract structured JSON',
model: 'zen-omni',
model: 'zen5-mini',
system: 'Respond with a single minified JSON object and nothing else.',
user: 'Extract name, role and company as JSON from: "Aoi Tanaka, the CTO at Hanzo, presented today."',
},
{
id: 'reasoning',
label: 'Step-by-step reasoning',
model: 'zen-omni',
model: 'zen5',
system: 'Think step by step, then give the final answer on its own line.',
user: 'A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?',
},
{
id: 'sql',
label: 'Write a SQL query',
model: 'zen-coder',
model: 'zen5-coder',
system: 'You are a senior data engineer. Return only the SQL.',
user: 'Given users(id, created_at) and orders(id, user_id, total), write SQL for the top 5 users by total spend in 2026.',
},
+1 -1
View File
@@ -53,7 +53,7 @@ function consented(): boolean {
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
//
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
// here.
//
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and