Compare commits

..
Author SHA1 Message Date
hanzo-dev f5c88aac0a publish with IAM and nothing else
Hanzo CI/CD / cicd (pull_request) Successful in 6m5s
CI/CD / cicd (pull_request) Successful in 6m6s
Identity is IAM everywhere, so the publish path may not hold a second kind of
credential. The hanzoai/ci `site:` lane does: it uploads with `mc mirror` and
refuses to publish without S3_ADMIN_ACCESS_KEY/SECRET_KEY, shared static
object-store keys that are not IAM and are not in KMS. The lane cannot express
an IAM-only upload, so it is dropped rather than bent, and `site:` comes back
out of hanzo.yml.

In its place, .hanzo/workflows/site.yml uploads through cloud's own zip route
(POST /v1/projects/hanzo-console/deploy) and promotes it (POST
/v1/sites/hanzo-console/publish) with ONE bearer for both calls.

That bearer is an IAM token, not a second identity. GitHub still holds only
KMS_CLIENT_ID/KMS_CLIENT_SECRET; cloud's /v1/kms/auth/login is a broker that
performs the IAM client_credentials exchange and returns IAM's JWT verbatim.
Measured: RS256, iss=https://hanzo.id, owner=hanzo, tokenType=access-token —
the same token minting it directly at hanzo.id produces. So nothing has to be
sealed in KMS for this to work, and there is no S3 credential in the path.

Publishing is `push: main` only, so a pull request cannot ship a release.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 18:11:08 -07:00
hanzo-dev a9079a1b17 the frontend ships on its own cadence
Hanzo CI/CD / cicd (pull_request) Successful in 4m9s
CI/CD / cicd (pull_request) Successful in 4m16s
A CSS fix used to cost a ~22 minute hanzoai/cloud build and 2m15s of
api.hanzo.ai, because the console shipped welded into the cloud binary:
console-embed image -> COPY --from=console /dist/ -> go:embed. Nobody pays
that for a frontend change, so nobody shipped, and the live console fell 13
commits behind main.

Declare the `site:` lane hanzoai/ci@v1 already implements. A push to main
builds the static export and POSTs /v1/sites/hanzo-console/publish: one
immutable release digested from its manifest, activated by a pointer flip,
rolled back the same way. Cloud is not in the path at all.

No new credential. Declaring `site:` is itself what makes ci fetch the
publish credential, and the bearer is the IAM JWT the workflow's single KMS
login already minted from KMS_CLIENT_ID/KMS_CLIENT_SECRET. One machine
identity, one thing to rotate, nothing added to git.

console-embed stays declared and Dockerfile.embed stays on disk: cloud still
consumes the image, so removing either here breaks cloud's build. Marked
deprecated, removed when the cloud-side PR lands. The Next.js server image
`console` is untouched -- it still serves admin.lux.cloud, admin.lux.network
and admin.zoo.cloud.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-05 17:36:49 -07:00
47 changed files with 391 additions and 4186 deletions
+77
View File
@@ -0,0 +1,77 @@
# The console ships as a SITE RELEASE, and identity is IAM only.
#
# This is deliberately NOT the hanzoai/ci `site:` lane. That lane uploads with
# `mc mirror` and refuses to publish without S3_ADMIN_ACCESS_KEY/SECRET_KEY —
# shared static object-store keys, not IAM — so it cannot express an IAM-only
# publish. It is not bent into one either: the upload here is cloud's OWN zip
# route, authorized by the SAME bearer as the publish that follows it. One
# issuer, one token, no S3 credential anywhere in the path.
#
# WHERE THE BEARER COMES FROM. GitHub holds only KMS_CLIENT_ID/KMS_CLIENT_SECRET,
# as everywhere else. cloud's /v1/kms/auth/login is not a second identity: it is a
# broker that performs the IAM client_credentials exchange and returns IAM's own
# JWT verbatim. Measured — the token it hands back is RS256 with
# `iss: https://hanzo.id`, `owner: hanzo`, `tokenType: access-token`, which is the
# same token minting it directly at hanzo.id/v1/iam/oauth/token produces. So this
# IS the IAM bearer, there is no second credential path, and nothing has to be
# sealed in KMS for it to work.
#
# Publishing is `push: main` only, so a pull request can never ship a release.
name: Site
on:
workflow_dispatch:
push:
branches: [main]
jobs:
publish:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable && pnpm install --frozen-lockfile
# The heap bump is load-bearing, not defensive: the full @hanzo/gui export
# OOMs into a stub shell without it. The analytics id is the public per-site
# id Dockerfile.embed baked, so the release tracks exactly as the embed did.
- run: pnpm build:embed
env:
NODE_OPTIONS: --max-old-space-size=8192
NEXT_PUBLIC_ANALYTICS_WEBSITE_ID: 7dce54ee-41f6-4751-96bf-fe005067c7c7
- name: Deploy + publish
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
run: |
set -euo pipefail
# node, not jq: this job already pins Node 22 above, so parsing with it
# assumes nothing about what the runner image ships.
jv () { node -p "JSON.parse(require('fs').readFileSync('$1','utf8'))['$2'] ?? ''"; }
post () { # url content-type curl-data-flag value
local code
code=$(curl -sS -o /tmp/resp.json -w '%{http_code}' -X POST "$1" \
-H "Authorization: Bearer $TOKEN" -H 'X-Org-Id: hanzo' \
-H "Content-Type: $2" "$3" "$4")
# Each non-2xx here is a different fix (402 hosting, 403 wrong org, 404
# no such site, 409 source moved, 413 too large), so print the body.
case "$code" in 2??) ;;
*) echo "::error::POST $1 -> HTTP $code"; cat /tmp/resp.json; exit 1 ;;
esac
}
node -e 'require("fs").writeFileSync("/tmp/login.json",JSON.stringify({clientId:process.env.KMS_CLIENT_ID,clientSecret:process.env.KMS_CLIENT_SECRET}))'
code=$(curl -sS -o /tmp/tok.json -w '%{http_code}' -X POST https://api.hanzo.ai/v1/kms/auth/login \
-H 'Content-Type: application/json' --data-binary @/tmp/login.json)
# Checked before parsing: the gateway answers a down KMS with a 503 whose
# body is `no available server`, and feeding that to a JSON parser turns an
# outage into a stack trace instead of the one line that names it.
case "$code" in 2??) ;;
*) echo "::error::KMS login -> HTTP $code"; cat /tmp/tok.json; echo; exit 1 ;;
esac
TOKEN=$(jv /tmp/tok.json accessToken)
[ -n "$TOKEN" ] || { echo "::error::KMS login returned no IAM bearer"; exit 1; }
echo "::add-mask::$TOKEN"
# index.html must be at the ZIP ROOT — cloud refuses a source without one.
( cd out && zip -qr ../site.zip . )
post https://api.hanzo.ai/v1/projects/hanzo-console/deploy application/zip --data-binary @site.zip
post https://api.hanzo.ai/v1/sites/hanzo-console/publish application/json -d '{"source":"hanzo-console"}'
echo "published $(jv /tmp/resp.json releaseId) ($(jv /tmp/resp.json objects) objects)"
+44 -47
View File
@@ -1,51 +1,48 @@
# 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.
# 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
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
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
# 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/"
# 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/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"]
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"]
+2 -90
View File
@@ -3588,8 +3588,8 @@ and the rail hid them.
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
now declare them; the icons the strips were carrying moved onto the declarations.
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
level 2 there. One declaration, two mounts — never two navs painting at once. It
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
imports it, so the sub-page icon defaults exist once.
@@ -3833,91 +3833,3 @@ reports signed out and the app sits on its loader forever. It emits base64URL no
which is what a JWT segment actually is. And the assistant's composer carries its own
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
Two changes that share a shape: something that looked finished was standing in for
the thing itself.
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
was reachable only as a form in a side pane, from a board you first had to have
agents to be looking at. `agents/quickstart` is the way someone with none starts:
describe what you want in a sentence, or take a template, then configure, run and
integrate.
- **Every step is an endpoint**, which is the whole design constraint. Describe →
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
of steps is a promise about what happens, and a step that only draws a checkmark
turns the promise into decoration. Steps 1 and 3 are optional by construction —
their loaders may be absent, and the step then says exactly what is missing.
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
`web.search` would name something that may not exist and would fail at the agent's
FIRST invocation rather than in the form. What a template CAN say truthfully is
`useTools` / `webSearch`, which are real switches in the agent contract.
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
bound and serving — one flat set spanning connector actions, functions, zap-service
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
empty answer, shown honestly rather than papered over.
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
match, and the live catalog does not carry that id — so the exact arm never fired and
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
selects `zen-embedding`. Every agent created without touching the model field was
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
were advertising the same dead id and two invented tool names; both now say things
that exist.
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
that product's sub-nav, behind a "Back to all products" button. The options were
identical either way — what the drill took away was every OTHER product, which is
precisely what someone needs when the reason they opened the rail was to go somewhere
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
indented on a hairline, `inert` when collapsed.
- **The label navigates; the chevron only opens and closes.** One target doing both
would make "show me what is in here" and "take me there" the same gesture.
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
categories are few and describe the catalog, so they open; products are many and each
brings four to eight rows, so opening them all would bury the catalog under its own
detail. The product you are IN is open unless you closed it, and that choice persists.
- **A pinned product appears twice** — once under Pinned, once in its category — and
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
which is the thing this rail exists to avoid, and it doubles the rail's height for no
information. The pinned copy owns it.
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
retargeted, not deleted: it now asserts "All products" is still on screen while a
product is open — the invariant the drill could never have satisfied — and that no
"Back to all products" button exists on any of the 18 converted products.
**ONE door.** The board's New-Agent button opened the builder in a side pane —
the same component, reached by a different shape, with no templates, no drafting,
and nowhere to run what it made. It goes to the quickstart now and
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
step, and the pane was the lesser of them. A spec clicks the board's CTA and
asserts the URL lands on `/agents/quickstart`.
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
reading the ones that exist, and the moments you most need the quickstart — no agents
yet, or the registry not answering — are exactly the ones those early returns swallow
it in.
-165
View File
@@ -1,165 +0,0 @@
/**
* e2e: the agent quickstart.
*
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
* composer, and a searchable template gallery beside it. These are assertions only a
* browser can make — that the two columns actually paint side by side at desktop,
* stack on a phone without the body scrolling sideways, and that picking a template
* carries its preset into the builder.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
/** Every backend 401s — this spec is about the SURFACE, not data. */
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
}
async function open(page: Page) {
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1500)
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
await expect(page.getByText('Browse templates')).toBeVisible()
// Step 1 is current; later steps are present but not yet reachable.
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
// The two columns sit SIDE BY SIDE — geometry, not source.
const composer = await page.getByLabel('Describe your agent').boundingBox()
const gallery = await page.getByText('Browse templates').boundingBox()
expect(composer && gallery).toBeTruthy()
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
await ctx.close()
})
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
await page.getByLabel('Search templates').fill('extract')
await page.waitForTimeout(400)
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
await page.getByLabel('Search templates').fill('')
await page.waitForTimeout(300)
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
await page.waitForTimeout(700)
// Step 2: the ONE builder, carrying the template's preset — the handle and the
// prompt the template declares, not an empty form.
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await expect(page.getByText(/You research questions/).first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
await ctx.close()
})
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
const scrolls = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
)
expect(scrolls, 'body must not scroll horizontally').toBe(false)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
await ctx.close()
})
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
await card.focus()
await expect(card).toBeFocused()
// The focus law lives in globals.css and keys off [tabindex] among others — a card
// that takes focus and shows nothing is worse than one that cannot be reached.
const ring = await card.evaluate((el) => {
const s = getComputedStyle(el)
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
})
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
// Enter picks it — the same thing a click does.
await page.keyboard.press('Enter')
await page.waitForTimeout(700)
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await ctx.close()
})
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
// Whichever New-Agent affordance the board is showing (header button or empty
// state), it must LAND on the quickstart — not open a second, differently-shaped
// create form in a side pane.
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
await cta.click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
await expect(page.getByText('What do you want to build?')).toBeVisible()
await ctx.close()
})
-355
View File
@@ -1,355 +0,0 @@
/**
* e2e: the Deploy section — mocked-network render + RESPONSIVE proof.
*
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole
* network mocked, so the shapes asserted here are the shapes cloud actually
* serves (bare arrays for projects/apps/sites, `{applications}` for the CD
* projection, `{builds}` for CI, `{buckets}` for storage) and nothing depends on
* live estate data.
*
* It proves: the section renders in the console's own chrome (left nav, org
* switcher, dark cards), the unified board folds APPS and SITES into one list,
* each of the six sub-pages renders its own panel, the deploy FORM opens and
* validates without posting, and at 390px the body never scrolls horizontally.
* Screenshots at desktop (1440) and mobile (390).
*
* Run: BASE_URL=http://localhost:4000 npx playwright test deploy-section
*/
import { test, expect, type Page, type Route } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
const SHOTS = join(process.cwd(), 'e2e-shots')
requireFixtureServer()
/** `GET /v1/platform/projects` — a bare array, as `apps/platform` serves it. */
const PROJECTS = [{ id: 'p1', org: 'hanzo', slug: 'web', name: 'Web', applications: 2, createdAt: 1_700_000_000_000 }]
/** `GET /v1/platform/projects/web/apps` — bare `appList`. */
const APPS = [
{
id: 'a1',
org: 'hanzo',
projectId: 'p1',
slug: 'api',
name: 'api',
source: 'git',
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git', branch: 'main' },
domains: ['api.hanzo.app', 'api.example.com'],
status: 'live',
phase: 'Running',
health: 'green',
replicas: 2,
port: 8080,
env: [],
updatedAt: 1_754_400_000_000,
},
{
id: 'a2',
org: 'hanzo',
projectId: 'p1',
slug: 'worker',
name: 'worker',
source: 'git',
repo: { url: 'https://git.hanzo.ai/hanzoai/worker.git' },
domains: [],
status: 'building',
replicas: 1,
env: [],
updatedAt: 1_754_300_000_000,
},
]
/** `GET /v1/platform/sites` — bare `projectsProjects`. */
const SITES = [
{
id: 's1',
org: 'hanzo',
slug: 'docs',
name: 'docs',
repo: { url: 'https://git.hanzo.ai/hanzoai/docs.git' },
framework: 'next',
status: 'live',
liveUrl: 'https://docs.hanzo.app',
createdAt: 1_754_000_000_000,
updatedAt: 1_754_350_000_000,
},
]
/** `GET /v1/deploy/applications` — the reconciliation projection. */
const CD = {
applications: [
{
name: 'api',
namespace: 'tenant-hanzo',
image: { repository: 'ghcr.io/hanzoai/api', tag: 'v1.4.2' },
phase: 'Running',
health: 'Healthy',
sync: 'Synced',
replicas: 2,
readyReplicas: 2,
liveTag: 'v1.4.2',
},
{
name: 'worker',
namespace: 'tenant-hanzo',
image: { repository: 'ghcr.io/hanzoai/worker', tag: 'v0.9.1' },
phase: 'Progressing',
health: 'Progressing',
sync: 'OutOfSync',
replicas: 1,
readyReplicas: 0,
liveTag: 'v0.9.0',
},
],
}
/** `GET /v1/builds`. */
const BUILDS = {
builds: [
{ id: 'b1', repo: 'hanzoai/api', commit: '9f2c1ab77d10', tag: 'v1.4.2', status: 'succeeded', startedAt: '2026-08-05T18:04:00Z', duration: '2m14s' },
{ id: 'b2', repo: 'hanzoai/worker', commit: '3ac9de00b412', tag: 'v0.9.1', status: 'building', startedAt: '2026-08-05T18:22:00Z', duration: '' },
],
}
/** `GET /v1/s3/buckets` — Unix SECONDS on `createdAt`, as the S3 app serves it. */
const BUCKETS = { buckets: [{ name: 'docs-site', createdAt: 1_754_000_000 }, { name: 'media', createdAt: 1_750_000_000 }] }
const json = (route: Route, body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
/**
* Mock every read the section makes, then answer everything else with an empty
* object so an unmocked call renders an honest empty state instead of hanging.
* Registered BEFORE `primeSession`, whose IAM handlers must win (Playwright
* matches routes in reverse registration order).
*/
async function mockNetwork(page: Page): Promise<void> {
await page.route('**/v1/**', (route) => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/v1/platform/projects')) return json(route, PROJECTS)
if (path.includes('/v1/platform/projects/') && path.endsWith('/apps')) return json(route, APPS)
if (path.endsWith('/v1/platform/sites')) return json(route, SITES)
if (path.endsWith('/v1/deploy/applications')) return json(route, CD)
if (path.endsWith('/v1/builds')) return json(route, BUILDS)
if (path.endsWith('/v1/s3/buckets')) return json(route, BUCKETS)
return json(route, {})
})
await primeSession(page)
}
/**
* Open a Deploy tab and wait for THAT tab's panel to paint.
*
* Keyed on the panel's own test id rather than a heading role: the console
* renders through Tamagui/react-native-web, where a `<Text>` title carries no
* implicit heading role, so `getByRole('heading')` matches nothing here.
*/
async function openDeploy(page: Page, tab = ''): Promise<void> {
await page.goto(`${BASE_URL}/deploy${tab ? `/${tab}` : ''}`, { waitUntil: 'domcontentloaded' })
const id = tab === '' ? 'deploy-board' : `deploy-panel-${tab}`
await expect(page.getByTestId(id)).toBeVisible({ timeout: 45_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the board folds apps and sites into one list, in the console chrome', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await openDeploy(page)
// Both backends, one board — the whole point of the section.
const board = page.getByTestId('deploy-board')
await expect(board.getByText('api', { exact: true })).toBeVisible()
await expect(board.getByText('docs', { exact: true })).toBeVisible()
await expect(board.getByText('api.hanzo.app', { exact: true })).toBeVisible()
// The counts are derived from the rows, never fabricated: 2 apps + 1 site,
// two of which the backend itself calls live.
await expect(board.getByText('Deployments', { exact: true })).toBeVisible()
await expect(board.getByText('3', { exact: true })).toBeVisible()
await expect(board.getByText('Sites', { exact: true })).toBeVisible()
// It is IN the console, not a bolt-on page: the shell's breadcrumb trail sits
// above it, and its level-2 nav is DECLARED. That strip hides itself at lg+ —
// the sidebar rail owns level 2 there — so it is asserted present, not visible.
await expect(page.getByTestId('subnav-deploy')).toHaveCount(1)
await expect(page.getByText('Home', { exact: true }).first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'deploy-board-desktop.png'), fullPage: false })
})
test('CD, CI and Storage each read their own canonical head', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1000 })
// CD — the reconciliation projection, showing declared → running drift.
await openDeploy(page, 'cd')
const cd = page.getByTestId('deploy-panel-cd')
await expect(cd.getByText('Synced', { exact: true })).toBeVisible()
await expect(cd.getByText('OutOfSync', { exact: true })).toBeVisible()
await expect(cd.getByText('v0.9.1 → v0.9.0', { exact: true })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'deploy-cd.png') })
// CI — the native build records.
await openDeploy(page, 'ci')
const ci = page.getByTestId('deploy-panel-ci')
await expect(ci.getByText('hanzoai/api', { exact: true })).toBeVisible()
await expect(ci.getByText('9f2c1ab77d10', { exact: true })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'deploy-ci.png') })
// Storage — org buckets.
await openDeploy(page, 'storage')
await expect(page.getByTestId('deploy-panel-storage').getByText('docs-site', { exact: true })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'deploy-storage.png') })
})
test('Domains lists EVERY bound host, including the custom one', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await openDeploy(page, 'domains')
const domains = page.getByTestId('deploy-panel-domains')
// `api` carries two hosts; a view folded to the primary would hide the second —
// which is precisely the domain someone bound on purpose.
await expect(domains.getByText('api.hanzo.app', { exact: true })).toBeVisible()
await expect(domains.getByText('api.example.com', { exact: true })).toBeVisible()
await expect(domains.getByText('docs.hanzo.app', { exact: true })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'deploy-domains.png') })
})
test('Apps and Sites narrow the SAME board', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await openDeploy(page, 'apps')
const apps = page.getByTestId('deploy-panel-apps')
await expect(apps.getByText('worker', { exact: true })).toBeVisible()
await expect(apps.getByText('docs', { exact: true })).toHaveCount(0)
await openDeploy(page, 'sites')
const sites = page.getByTestId('deploy-panel-sites')
await expect(sites.getByText('docs', { exact: true })).toBeVisible()
await expect(sites.getByText('worker', { exact: true })).toHaveCount(0)
})
test('the deploy form opens, derives a name, and refuses a bad host without posting', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1200 })
// A refused form must not create anything. Scoped to the DEPLOY writes —
// the console shell PATCHes its own UI preferences on navigation, which is
// unrelated traffic and would make a blanket "no writes" assertion a lie.
const writes: string[] = []
page.on('request', (r) => {
const u = r.url()
if (r.method() !== 'GET' && (u.includes('/v1/platform/') || u.includes('/v1/projects'))) {
writes.push(`${r.method()} ${u}`)
}
})
await openDeploy(page)
await page.getByRole('button', { name: 'New deployment' }).click()
const form = page.getByTestId('new-deploy')
await expect(form).toBeVisible()
// The name follows the repo until someone edits it by hand.
await form.getByPlaceholder('https://git.hanzo.ai/hanzoai/console.git').fill('https://git.hanzo.ai/hanzoai/console.git')
// `exact` matters: the repo field's own placeholder CONTAINS "console".
await expect(form.getByPlaceholder('console', { exact: true })).toHaveValue('console')
// A URL in the host field is refused in the form, before any request.
await form.getByPlaceholder('app.example.com').fill('https://bad.example.com')
// The form REFUSES rather than posting: Deploy is disabled and says why. Scoped
// to the form because "Deploy" is also the nav item and the breadcrumb leaf.
await expect(form.getByRole('alert')).toContainText('https://')
await expect(form.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled()
// Correcting the host clears the refusal and arms the button.
await form.getByPlaceholder('app.example.com').fill('app.example.com')
await expect(form.getByRole('alert')).toHaveCount(0)
await expect(form.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled()
await page.screenshot({ path: join(SHOTS, 'deploy-form.png') })
expect(writes, 'a rejected form must not create an app or a site').toEqual([])
})
test('every env var is SEALED by default, and only a named one opens', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 1440, height: 1400 })
await openDeploy(page)
await page.getByRole('button', { name: 'New deployment' }).click()
const form = page.getByTestId('new-deploy')
// Credential names a key-NAME regex would have missed, plus plain config.
await form.getByRole('textbox').last().fill('STRIPE_SK=sk_live_x\nGH_PAT=ghp_x\nDB_PASS=hunter2\nPORT=8080')
const vars = form.getByTestId('env-vars')
await expect(vars).toBeVisible()
// Default is sealed for ALL FOUR — including the three the old regex let through.
for (const key of ['STRIPE_SK', 'GH_PAT', 'DB_PASS', 'PORT']) {
await expect(vars.getByRole('button', { name: `${key} Sealed` })).toHaveAttribute('aria-pressed', 'true')
}
// Opening PORT opens ONLY PORT.
await vars.getByRole('button', { name: 'PORT Public' }).click()
await expect(vars.getByRole('button', { name: 'PORT Public' })).toHaveAttribute('aria-pressed', 'true')
await expect(vars.getByRole('button', { name: 'STRIPE_SK Sealed' })).toHaveAttribute('aria-pressed', 'true')
await page.screenshot({ path: join(SHOTS, 'deploy-env-secrets.png') })
// A Public mark must not outlive its line: delete PORT, retype it, and it comes
// back SEALED like any new variable rather than inheriting the old mark.
const env = form.getByRole('textbox').last()
await env.fill('STRIPE_SK=sk_live_x')
await expect(vars.getByRole('button', { name: 'PORT Sealed' })).toHaveCount(0)
await env.fill('STRIPE_SK=sk_live_x\nPORT=9090')
await expect(vars.getByRole('button', { name: 'PORT Sealed' })).toHaveAttribute('aria-pressed', 'true')
})
test('a half-loaded board names the gap and shows no count it cannot know', async ({ page }) => {
// Sites answer; the APPS fan-out fails. The board must not render "Apps 0".
await page.route('**/v1/**', (route) => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/v1/platform/projects')) return json(route, PROJECTS)
if (path.includes('/v1/platform/projects/') && path.endsWith('/apps')) {
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom"}' })
}
if (path.endsWith('/v1/platform/sites')) return json(route, SITES)
return json(route, {})
})
await primeSession(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await openDeploy(page)
const board = page.getByTestId('deploy-board')
await expect(board.getByRole('status')).toContainText('Apps could not be fully loaded')
// The site that DID load still renders — a partial read is not an outage.
await expect(board.getByText('docs', { exact: true })).toBeVisible()
// Sites is knowable (1); Apps and the totals are not.
await expect(board.getByText('1', { exact: true })).toBeVisible()
await expect(board.getByText('—', { exact: true }).first()).toBeVisible()
// The Domains list inherits the same gap, and says so.
await openDeploy(page, 'domains')
await expect(page.getByTestId('deploy-panel-domains').getByRole('status')).toContainText('Apps could not be fully loaded')
await page.screenshot({ path: join(SHOTS, 'deploy-partial.png') })
})
test('at 390px the body never scrolls horizontally', async ({ page }) => {
await mockNetwork(page)
await page.setViewportSize({ width: 390, height: 844 })
await openDeploy(page)
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(overflow, 'the page must not scroll sideways on a phone').toBeLessThanOrEqual(1)
await page.screenshot({ path: join(SHOTS, 'deploy-board-mobile.png'), fullPage: false })
})
+13 -24
View File
@@ -2,15 +2,9 @@
* e2e: ONE level-2 nav.
*
* Clicking into a product must reveal ITS options rather than replacing the screen,
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
* competing tab strip in the content, which is what `/models` used to do (eight items
* in the rail, four in the content, disagreeing on the index's own name).
*
* "Rather than replacing the screen" is now literal on both axes: the product's
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
* used to swap itself for the product's sub-nav behind a "Back to all products"
* button, so these specs assert the other products are still there — that is the
* whole point of the change, and the part a future drill would silently undo.
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
* a competing tab strip in the content, which is what `/models` used to do (eight
* items in the rail, four in the content, disagreeing on the index's own name).
*
* These are assertions only a browser can make. They read COMPUTED style and
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
@@ -82,12 +76,8 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
const page = await ctx.newPage()
await open(page, '/models')
// The rail expanded Models in place — and did NOT swap itself for it.
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
// The rest of the catalog is still there — "All products" sits at the FOOT of the
// product list, so its presence proves the list was never swapped away. This is the
// assertion the drill could not have passed.
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
// The rail drilled into Models and shows the product's own options.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
@@ -176,10 +166,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models')
// Models is still expanded with the same options — browser Back moved the ROUTE,
// and the rail followed it without collapsing what the user was looking at.
// Still drilled into Models with the same options — Back moved the LEVEL, it did
// not throw the user out to the product list.
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
await ctx.close()
@@ -187,10 +177,9 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
/**
* Every product that used to carry its own `const TABS` — the whole conversion, in
* one sweep. For each: the page renders, the rail expands it in place, and the
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
* invariant, and it is the thing that regresses the moment someone adds a tab bar
* back.
* one sweep. For each: the page renders, the rail drills into it, and the content
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
* and it is the thing that regresses the moment someone adds a tab bar back.
*/
const CONVERTED = [
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
@@ -211,8 +200,8 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
).toBe('none')
await expect(
page.getByRole('button', { name: 'Back to all products' }),
`${id}: the rail expands in place — it must never swap itself for one product`,
).toHaveCount(0)
`${id}: the rail drilled in`,
).toBeVisible()
}
await ctx.close()
+34 -29
View File
@@ -1,42 +1,47 @@
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
#
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
# only who serves it:
# The frontend ships as a SITE RELEASE — .hanzo/workflows/site.yml. A push to main
# builds the static export and promotes it on /v1/sites with an IAM bearer, and that
# is the whole path: no image, no cloud rebuild, rollback is a pointer flip. It is
# NOT the hanzoai/ci `site:` lane — that lane uploads with `mc mirror` and refuses
# to publish without static S3 admin keys, which is a second, non-IAM credential.
#
# 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.
# It used to ship welded INTO the cloud binary via the console-embed image below
# (cloud `COPY --from=console /dist/` → go:embed), which priced a CSS fix at a
# ~22-minute cloud build and a 2m15s api.hanzo.ai outage. Nobody pays that for a
# frontend change, so nobody shipped: the live console ran 13 commits behind this
# branch. The embed image stays declared until cloud stops consuming it.
#
# 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.
# hanzoai/ci pushes each `images:` entry to `repo:` (GHCR) and server-side-mirrors
# to registry.hanzo.ai automatically.
#
# 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.
# 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.
#
# 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.
images:
# DEPRECATED — superseded by the `site:` release below. Kept only because
# hanzoai/cloud still does `FROM ...console-embed AS console`; dropping it here
# (or deleting Dockerfile.embed) breaks cloud's build. Remove both once the
# cloud-side PR that stops consuming it merges.
- name: console-embed
context: .
dockerfile: Dockerfile.embed
repo: ghcr.io/hanzoai/console-embed
# 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.
# 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.
- name: console
context: .
dockerfile: Dockerfile
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@hanzo/console",
"version": "8.5.63",
"version": "8.5.50",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
+7 -32
View File
@@ -58,32 +58,18 @@ const CUSTOM = '__custom__'
export function AgentBuilder({
loaders,
initial,
onCreated,
onCancel,
submitLabel = 'Create agent',
}: {
loaders: AgentBuilderLoaders
/**
* A spec to start from — a template's preset, or what a description drafted.
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
* never overwrite something they have already typed. A host that swaps seeds
* (the quickstart, when a different template is picked) remounts with a `key`,
* which states the intent — a new starting point — instead of hiding it in an
* effect that races the user's keystrokes.
*/
initial?: Partial<AgentSpec>
/**
* Called after a successful create, with the NAME the agent was created under
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
* straight to running it rather than looking it back up.
*/
onCreated: (name: string) => void
/** Called after a successful create (the host reloads its list + closes the form). */
onCreated: () => void
/** Called when the user cancels (optional — omit for an always-open form). */
onCancel?: () => void
submitLabel?: string
}) {
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
@@ -179,9 +165,8 @@ export function AgentBuilder({
setError(null)
setUnavailable(false)
try {
const body = toCreateBody(spec)
await loaders.createAgent(body)
onCreated(body.name)
await loaders.createAgent(toCreateBody(spec))
onCreated()
} catch (e) {
const c = classifyBuilderError(e)
if (c.kind === 'unavailable') setUnavailable(true)
@@ -217,10 +202,7 @@ export function AgentBuilder({
loading={models.phase === 'loading'}
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
onRetry={loadModels}
// A placeholder is an example, and an example that does not exist is a lie
// the user only discovers at the agent's first run. These are ids the live
// catalog actually serves; the field itself offers the real list.
placeholder="zen5 · zen5-mini · claude-sonnet-5"
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
/>
</FieldRow>
@@ -257,14 +239,7 @@ export function AgentBuilder({
loading={tools.phase === 'loading'}
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
onRetry={loadTools}
// No invented examples here either: the tool plane is per-org, so nobody
// can name a tool that is certain to exist. The field offers what the org
// has actually activated, and stays typeable for what it has not.
placeholder={
tools.phase === 'ready' && tools.options.length === 0
? 'No tools activated yet — type one to use it anyway'
: 'Search your tools'
}
placeholder="add a tool — e.g. web.search, code.exec"
emptyText="Press Add to include what you typed."
/>
<XStack gap="$2">
-532
View File
@@ -1,532 +0,0 @@
'use client'
/**
* AgentQuickstart — the guided way into the ONE builder: describe an agent in your
* own words or start from a template, configure it, run it, and take the call away.
*
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
* ladder of steps is a promise about what happens; a step that only draws a checkmark
* turns the promise into decoration. So:
*
* 1 Describe → `POST /v1/chat/completions` drafts a spec from a sentence
* (`draftAgent`), or a template fills the form with a preset
* 2 Configure → the SAME `AgentBuilder` every other surface uses, seeded
* 3 Run → `POST /v1/agents/:ref/run` executes it and shows the recorded run
* 4 Integrate → the request that just worked, as code
*
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
* may be absent, and the step then says exactly what is missing instead of miming it.
* Step 2 is the only one that cannot be skipped, because creating the agent is the
* point and the builder is the one thing that does it.
*
* Host-agnostic like the rest of the module: everything arrives through
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
*/
import { useMemo, useState } from 'react'
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
import { AgentBuilder } from './AgentBuilder'
import { defaultConfig, emptySpec, proposeName } from './logic'
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
/** The four steps, in order. The id is what the component switches on. */
const STEPS = [
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
{ id: 'configure', label: 'Configure', endpoint: '' },
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
{ id: 'integrate', label: 'Integrate', endpoint: '' },
] as const
type StepId = (typeof STEPS)[number]['id']
/**
* The step ladder. A step reached earlier is a real link back — going back to change
* the prompt is the most common thing a person wants here, and a ladder you cannot
* climb down is a worse version of a heading.
*/
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
const index = STEPS.findIndex((s) => s.id === current)
return (
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
{STEPS.map((s, i) => {
const done = i < index
const active = i === index
return (
<XStack key={s.id} items="center" gap="$2" role="listitem">
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
<Button
size="$2"
chromeless
px="$2"
disabled={i > index}
onPress={() => onGo(s.id)}
opacity={i > index ? 0.45 : 1}
aria-current={active ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
>
<XStack items="center" gap="$2">
<XStack
width={20}
height={20}
rounded="$10"
items="center"
justify="center"
bg={done || active ? '$color12' : 'transparent'}
borderWidth={done || active ? 0 : 1}
borderColor="$borderColor"
>
{done ? (
<Check size={12} color="$color1" />
) : (
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
{i + 1}
</Text>
)}
</XStack>
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
{s.label}
</Text>
{active && s.endpoint ? (
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
{s.endpoint}
</Text>
) : null}
</XStack>
</Button>
</XStack>
)
})}
</XStack>
)
}
/** One template card in the gallery. The whole card is the control. */
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
return (
<YStack
onPress={onPick}
cursor="pointer"
role="button"
tabIndex={0}
focusable
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault?.()
onPick()
}
}}
gap="$1.5"
p="$3"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
aria-label={`Start from ${template.title}`}
>
<Text fontSize="$3" fontWeight="700" color="$color12">
{template.title}
</Text>
<Text fontSize="$2" color="$color11">
{template.summary}
</Text>
</YStack>
)
}
/** A short, quiet note — used wherever a step has to say what is missing. */
function Note({ children }: { children: React.ReactNode }) {
return (
<Text fontSize="$2" color="$color10">
{children}
</Text>
)
}
export function AgentQuickstart({
loaders,
onFinished,
apiBase = 'https://api.hanzo.ai',
}: {
loaders: AgentBuilderLoaders
/** Called when the user leaves the quickstart with an agent created (host reloads). */
onFinished?: (name: string) => void
/** The API origin the integrate snippet should show. */
apiBase?: string
}) {
const [step, setStep] = useState<StepId>('describe')
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
// rather than an effect racing whatever the user has already typed.
const [seedKey, setSeedKey] = useState(0)
const [created, setCreated] = useState<string | null>(null)
// ── Step 1: describe ──────────────────────────────────────────────────────
const [description, setDescription] = useState('')
const [drafting, setDrafting] = useState(false)
const [draftError, setDraftError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const templates = useMemo(() => searchTemplates(query), [query])
const start = (next: Partial<AgentSpec>) => {
setSeed(next)
setSeedKey((k) => k + 1)
setStep('configure')
}
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
const describe = async () => {
const text = description.trim()
if (!text || drafting) return
// Whatever happens next, the user's own words are already worth something: they
// are the description, and they propose the handle. A draft only ever ADDS to
// this, so a failed or absent draft still lands them in a part-filled form.
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
if (!loaders.draftAgent) {
start(fallback)
return
}
setDrafting(true)
setDraftError(null)
try {
const drafted = await loaders.draftAgent(text)
start({ ...fallback, ...drafted })
} catch (e) {
// Say why, and still go — being stranded on a spinner is worse than writing
// the prompt yourself.
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
start(fallback)
} finally {
setDrafting(false)
}
}
// ── Step 3: run ───────────────────────────────────────────────────────────
const [input, setInput] = useState('')
const [running, setRunning] = useState(false)
const [run, setRun] = useState<AgentRunResult | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const doRun = async () => {
const text = input.trim()
if (!text || !created || !loaders.runAgent || running) return
setRunning(true)
setRunError(null)
setRun(null)
try {
setRun(await loaders.runAgent(created, text))
} catch (e) {
// A failed run answers 502 with the RUN as its body, so this message is the
// run's own reason — not a generic transport failure.
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
} finally {
setRunning(false)
}
}
const snippet = useMemo(
() =>
[
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
].join('\n'),
[apiBase, created, input],
)
return (
<YStack gap="$4">
<StepLadder current={step} onGo={setStep} />
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
{step === 'describe' ? (
<XStack gap="$4" items="flex-start" flexWrap="wrap">
<YStack flex={2} minW={320} gap="$3" py="$6">
<YStack gap="$2" items="center" py="$4">
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
What do you want to build?
</Text>
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
Describe your agent, or start from a template.
</Text>
</YStack>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$7"
px="$3"
py="$2.5"
gap="$2"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={description}
onChangeText={setDescription}
placeholder="Describe your agent…"
numberOfLines={3}
disabled={drafting}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Describe your agent"
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
// is never a send — an open candidate window must not submit the turn.
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void describe()
}
}}
/>
<Button
size="$2"
circular
theme="light"
disabled={!description.trim() || drafting}
onPress={() => void describe()}
icon={drafting ? undefined : <ArrowRight size={16} />}
aria-label="Draft this agent"
>
{drafting ? <Spinner size="small" /> : undefined}
</Button>
</XStack>
</YStack>
{!loaders.draftAgent ? (
<Note>
Drafting isnt connected here, so your words become the agents description and handle and you
write the prompt in the next step.
</Note>
) : null}
{draftError ? (
<Text fontSize="$2" color="$red10">
{draftError}
</Text>
) : null}
</YStack>
{/* Templates — a real gallery, searchable, each card a preset the builder
can already express. */}
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="700" color="$color12">
Browse templates
</Text>
<XStack
items="center"
gap="$2"
px="$2.5"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
data-field-box
>
<Search size={14} opacity={0.6} />
<Input
flex={1}
unstyled
value={query}
onChangeText={setQuery}
placeholder="Search templates"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
aria-label="Search templates"
/>
{query ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
) : null}
</XStack>
<ScrollView maxH={520}>
<YStack gap="$2">
{templates.map((t) => (
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
))}
{templates.length === 0 ? (
<Note>No template matches {query.trim()}. Describe it instead that always works.</Note>
) : null}
</YStack>
</ScrollView>
</YStack>
</XStack>
) : null}
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
{step === 'configure' ? (
<YStack gap="$3" maxW={720}>
<AgentBuilder
key={seedKey}
loaders={loaders}
initial={seed}
onCancel={() => setStep('describe')}
onCreated={(name) => {
setCreated(name)
setStep('run')
onFinished?.(name)
}}
/>
</YStack>
) : null}
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
{step === 'run' && created ? (
<YStack gap="$3" maxW={720}>
<XStack items="center" gap="$2">
<Bot size={16} />
<Text fontSize="$5" fontWeight="800" color="$color12">
{created}
</Text>
<Text fontSize="$2" color="$color10">
is live
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
Send it something. This runs the agent for real and bills the run to your organization.
</Text>
{loaders.runAgent ? (
<>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$5"
px="$3"
py="$2.5"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={input}
onChangeText={setInput}
placeholder="Your message to the agent…"
numberOfLines={3}
disabled={running}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Message to the agent"
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void doRun()
}
}}
/>
<Button
size="$2"
theme="light"
disabled={!input.trim() || running}
onPress={() => void doRun()}
icon={running ? undefined : <Play size={15} />}
>
{running ? <Spinner size="small" /> : 'Run'}
</Button>
</XStack>
</YStack>
{runError ? (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" fontWeight="700" color="$red10">
The run failed
</Text>
<Text fontSize="$2" color="$color11">
{runError}
</Text>
</Card>
) : null}
{run ? (
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2" flexWrap="wrap">
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
{run.status === 'ok' ? 'ok' : run.status || 'error'}
</Text>
{run.model ? (
<Text fontSize="$1" color="$color10">
{run.model}
</Text>
) : null}
{run.durationMs != null ? (
<Text fontSize="$1" color="$color10">
{run.durationMs} ms
</Text>
) : null}
</XStack>
<Text fontSize="$3" color="$color12">
{run.output || run.error || 'The run recorded no output.'}
</Text>
</Card>
) : null}
</>
) : (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Terminal size={14} />
<Text fontSize="$3" fontWeight="700" color="$color12">
Running from here isnt connected on this deployment
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
The agent exists and `POST /v1/agents/{created}/run` is its endpoint the next step shows the
call.
</Text>
</Card>
)}
<XStack gap="$2">
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
Integrate
</Button>
</XStack>
</YStack>
) : null}
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
{step === 'integrate' && created ? (
<YStack gap="$3" maxW={720}>
<Text fontSize="$5" fontWeight="800" color="$color12">
Call it from your code
</Text>
<Text fontSize="$2" color="$color11">
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
</Text>
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
{snippet}
</Text>
</YStack>
<Note>
It answers with the recorded run its id, status, model, output and duration. A model failure comes
back as a run with `status: "error"` and the reason, never as silence.
</Note>
</YStack>
) : null}
</YStack>
)
}
-8
View File
@@ -9,12 +9,10 @@
* lifts cleanly into a published `@hanzo/agent-builder` package.
*/
export { AgentBuilder } from './AgentBuilder'
export { AgentQuickstart } from './Quickstart'
export type {
AgentSpec,
AgentConfig,
AgentCreateBody,
AgentRunResult,
ReasoningEffort,
AgentBuilderLoaders,
BuilderOption,
@@ -36,10 +34,4 @@ export {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
draftInstruction,
parseDraft,
proposeName,
toHandle,
} from './logic'
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
export type { AgentTemplate } from './templates'
+5 -82
View File
@@ -14,9 +14,6 @@ import {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
proposeName,
toHandle,
parseDraft,
} from './logic'
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
@@ -35,21 +32,13 @@ describe('defaultModel', () => {
expect(defaultModel([])).toBe('')
})
it('prefers the exact zen5 default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
it('prefers the exact zen-omni default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
})
it('falls back to another Zen TEXT model', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
})
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
// conversation — as the default model for every new agent.
it('never defaults to a modality SKU over a text model', () => {
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
expect(defaultModel(live)).toBe('zen5')
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
})
it('falls back to the first catalog id when no Zen model exists', () => {
@@ -200,69 +189,3 @@ describe('classifyBuilderError', () => {
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
})
})
describe('proposeName', () => {
it('makes a handle out of the words a person actually typed', () => {
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
})
it('drops noise words and punctuation', () => {
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
})
it('is empty when there is nothing usable', () => {
expect(proposeName(' ')).toBe('')
expect(proposeName('a the it')).toBe('')
})
it('caps the length so the handle stays a handle', () => {
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
})
})
describe('toHandle', () => {
it('reshapes without re-wording — a handle survives intact', () => {
expect(toHandle('support-agent')).toBe('support-agent')
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
})
it('collapses runs and trims the edges', () => {
expect(toHandle(' --a // b-- ')).toBe('a-b')
})
it('caps the length and never ends on a hyphen', () => {
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
expect(h.length).toBeLessThanOrEqual(32)
expect(h.endsWith('-')).toBe(false)
})
})
describe('parseDraft', () => {
it('reads the three fields it asked for', () => {
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
})
// The two things models actually do to JSON.
it('survives a code fence and surrounding prose', () => {
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
})
it('normalizes a handle the backend would refuse', () => {
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
})
it('accepts the snake_case and bare spellings of the prompt', () => {
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
})
// A creative answer may only ever produce LESS than asked, never a field the
// builder cannot express.
it('drops every key it does not recognize', () => {
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
expect(d).toEqual({ name: 'a-b' })
})
it('is null when there is no object, or only empty fields', () => {
expect(parseDraft('I could not do that.')).toBeNull()
expect(parseDraft('{ not json }')).toBeNull()
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
})
})
+9 -121
View File
@@ -23,30 +23,23 @@ export function defaultConfig(): AgentConfig {
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
}
/** The Zen text model to preselect when the catalog offers it. */
const ZEN_DEFAULT = 'zen5'
/** The default Zen model to preselect when the catalog offers one. */
const ZEN_DEFAULT = 'zen-omni'
/**
* Pick a sensible default model from a live catalog: the Zen default if present,
* else another model from the Zen TEXT family, else the first catalog id, else ''
* (nothing to default to — the field stays empty/typeable). PURE. Never invents an
* id — only returns one the catalog actually lists.
*
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
* text models, while `zen-<noun>` names a MODALITY — zen-embedding, zen-image,
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
* without touching the model field was pointed at an embeddings SKU that cannot hold a
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
* the live catalog does not carry, so the fallback was always the arm that ran.)
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
* PURE. Never invents an id — only returns one the catalog actually lists.
*/
export function defaultModel(options: BuilderOption[]): string {
if (options.length === 0) return ''
const exact = options.find((o) => o.value === ZEN_DEFAULT)
if (exact) return exact.value
const zenText = options.find((o) => /^zen\d/i.test(o.value))
return (zenText ?? options[0]).value
const zen = options.find(
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
)
return (zen ?? options[0]).value
}
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
@@ -157,111 +150,6 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
}
// ── Drafting an agent from a sentence ───────────────────────────────────────
//
// The quickstart lets someone describe an agent in their own words. That is a
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
// what lives here is the pure half — the instruction we send, and the parse of what
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
// against real malformed answers rather than trusted.
/**
* The instruction that turns a description into a spec. It asks for the three
* fields a person would otherwise type and NOTHING else — deliberately not `model`
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
* in its tool plane, and a model asked to name one will happily invent it. Those two
* fields stay with the pickers that know the real answers. PURE.
*/
export function draftInstruction(): string {
return [
'You turn a description of an agent into its definition.',
'',
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
' "description" one sentence on what the agent does',
' "systemPrompt" the agent\'s own instructions, written in the second person',
'',
'The system prompt is the real work: state what the agent does, what it must not do,',
'and how it should behave when it is unsure. Write it as instructions to the agent,',
'not as a description of it.',
].join('\n')
}
/** Stop-words that carry no meaning in a handle. */
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
/**
* Put any string into handle FORM: lowercase, letters and digits kept, everything
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
* re-words — `support-agent` stays `support-agent`. PURE.
*/
export function toHandle(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32)
.replace(/-+$/, '')
}
/**
* A handle proposed from PROSE — the user's own sentence — so the form is never left
* with an empty required field even when the draft call fails. Drops the words that
* carry no meaning in a handle, keeps the first three that do, and puts the result in
* handle form. Returns '' when the text carries nothing usable.
*
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
* already-formed handle silently renames it — `support-agent` would come back as
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
*/
export function proposeName(description: string): string {
const words = description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.split(/[\s-]+/)
.filter((w) => w.length > 1 && !NOISE.has(w))
return toHandle(words.slice(0, 3).join('-'))
}
/**
* Read a drafted spec out of a model's answer. Tolerant of the two things models
* actually do — wrapping the object in a ```json fence, and adding a sentence before
* or after it — by taking the outermost braces. Every field is validated and
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
* than asked, never a field the builder does not understand. Returns null when there
* is no object at all. PURE.
*/
export function parseDraft(answer: string): Partial<AgentSpec> | null {
const start = answer.indexOf('{')
const end = answer.lastIndexOf('}')
if (start < 0 || end <= start) return null
let raw: unknown
try {
raw = JSON.parse(answer.slice(start, end + 1))
} catch {
return null
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
const out: Partial<AgentSpec> = {}
const name = text(r.name)
// A handle the backend would refuse is worse than none, so reshape it — but with
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
// the model was asked for a handle, not a sentence.
if (name) {
const handle = toHandle(name)
if (handle) out.name = handle
}
const description = text(r.description)
if (description) out.description = description
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
if (systemPrompt) out.systemPrompt = systemPrompt
return Object.keys(out).length ? out : null
}
/**
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
@@ -1,110 +0,0 @@
import { describe, it, expect } from 'vitest'
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
import { defaultConfig, emptySpec, toCreateBody } from './logic'
describe('AGENT_TEMPLATES', () => {
it('has unique ids and a handle for every entry', () => {
const ids = AGENT_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
for (const t of AGENT_TEMPLATES) {
expect(t.name.trim()).not.toBe('')
expect(t.title.trim()).not.toBe('')
expect(t.summary.trim()).not.toBe('')
}
})
it('leads with the blank one — starting from nothing is the honest default', () => {
expect(AGENT_TEMPLATES[0].id).toBe('blank')
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
})
// The whole point of the module doc: a template is a preset, never a promise. It
// may only carry fields the create body can already express, so picking one can
// never produce an agent the builder itself could not.
it('carries nothing the create body cannot express', () => {
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
for (const t of AGENT_TEMPLATES) {
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
}
})
// A hardcoded tool id would name something the org may not have activated, and it
// would fail at the agent's FIRST invocation rather than here. Tools come from the
// live tool plane or not at all.
it('names no tools — those come from the live tool plane', () => {
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
})
it('every template produces a submittable body', () => {
for (const t of AGENT_TEMPLATES) {
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
expect(body.name).toBe(t.name)
expect(body.description).toBe(t.summary)
}
})
})
describe('matchTemplate / searchTemplates', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
it('an empty query matches everything', () => {
expect(matchTemplate(t, '')).toBe(true)
expect(matchTemplate(t, ' ')).toBe(true)
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
})
it('matches title, summary and id, case-insensitively', () => {
expect(matchTemplate(t, 'DEEP')).toBe(true)
expect(matchTemplate(t, 'sources')).toBe(true)
expect(matchTemplate(t, 'researcher')).toBe(true)
})
it('returns nothing for a query nothing carries', () => {
expect(searchTemplates('quantum tuba')).toEqual([])
})
it('keeps gallery order', () => {
const found = searchTemplates('a').map((x) => x.id)
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
})
})
describe('templateById', () => {
it('finds one, and is null for an unknown id', () => {
expect(templateById('blank')?.title).toBe('Blank agent')
expect(templateById('nope')).toBeNull()
})
})
describe('specFromTemplate', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
it('fills name, description and prompt from the template', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.name).toBe(t.name)
expect(s.description).toBe(t.summary)
expect(s.systemPrompt).toBe(t.systemPrompt)
})
// The template owns the agent's character; the MODEL is the org's own decision and
// its tool list is the org's too, so neither is overwritten by picking one.
it('keeps a model and tools the user already chose', () => {
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
const s = specFromTemplate(t, current, defaultConfig())
expect(s.model).toBe('zen5-pro')
expect(s.tools).toEqual(['already.picked'])
})
it('merges the template config over the defaults, leaving the rest alone', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.config?.temperature).toBe(0)
expect(s.config?.stream).toBe(defaultConfig().stream)
})
it('posts no config for a template that needs none', () => {
const blank = templateById('blank')!
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
})
})
-175
View File
@@ -1,175 +0,0 @@
/**
* Agent templates — starting points for the ONE builder, shared by every surface.
*
* A template is a PRESET, never a promise: every field it carries maps to something
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
* happens — the user still sees, edits and submits the same form, so a template can
* never create an agent the builder itself could not.
*
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
* which knows what an org has actually activated; a hardcoded `web.search` here would
* name something that may not exist and would fail on the agent's first invocation.
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
* real switches in the agent contract, so a template that needs them turns them on
* and the builder's live tool picker fills in the specifics.
*
* Pure data + pure helpers — no React, no I/O — so this lifts into
* `@hanzo/agent-builder` with the rest of the module.
*/
import type { AgentConfig, AgentSpec } from './types'
/** A named starting point: what it is, and the spec it fills the builder with. */
export type AgentTemplate = {
/** Stable id — the URL/search key. */
id: string
/** What it is called in the gallery. */
title: string
/** One line on what the agent does. Shown on the card and searched. */
summary: string
/** The seed handle; the user renames freely before submitting. */
name: string
/** The system prompt this template starts from ('' for the blank one). */
systemPrompt: string
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
config?: Partial<AgentConfig>
}
/**
* The gallery, in display order. `blank` leads because starting from nothing is the
* honest default — everything after it is a real, specific job.
*/
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
{
id: 'blank',
title: 'Blank agent',
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
name: 'my-agent',
systemPrompt: '',
},
{
id: 'researcher',
title: 'Deep researcher',
summary: 'Researches a question across the web and answers with the sources it used.',
name: 'researcher',
systemPrompt:
'You research questions and report what you found.\n\n' +
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
'Prefer primary sources over summaries of them.\n\n' +
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
'useful than a confident guess.',
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
},
{
id: 'extractor',
title: 'Structured extractor',
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
name: 'extractor',
systemPrompt:
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
'text supports literally.',
config: { temperature: 0, topP: 1 },
},
{
id: 'support',
title: 'Support answerer',
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
name: 'support',
systemPrompt:
'You answer product questions for customers, using the material available to you.\n\n' +
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
'improvising — a wrong answer costs more than a slow one.\n\n' +
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
'or a refund you cannot point to in the material.',
config: { useTools: true, temperature: 0.3 },
},
{
id: 'reviewer',
title: 'Code reviewer',
summary: 'Reads a diff and reports what will actually break, most severe first.',
name: 'reviewer',
systemPrompt:
'You review code changes.\n\n' +
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
},
{
id: 'analyst',
title: 'Data analyst',
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
name: 'analyst',
systemPrompt:
'You explain datasets to people who have to make a decision from them.\n\n' +
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
config: { useTools: true, temperature: 0.2 },
},
{
id: 'summarizer',
title: 'Meeting summarizer',
summary: 'Turns a transcript into decisions, owners and the questions still open.',
name: 'summarizer',
systemPrompt:
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
'owner unassigned and say so.',
config: { temperature: 0.2 },
},
{
id: 'triage',
title: 'Incident triager',
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
name: 'triage',
systemPrompt:
'You triage incoming incident reports.\n\n' +
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
'severity stays provisional. Never guess an area to avoid leaving one blank.',
config: { temperature: 0.2, reasoningEffort: 'medium' },
},
]
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
export function matchTemplate(t: AgentTemplate, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
}
/** The templates matching a query, in gallery order. */
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
return templates.filter((t) => matchTemplate(t, query))
}
/** The template with this id, or null. */
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
return templates.find((t) => t.id === id) ?? null
}
/**
* The builder state a template starts from. Merged over the CURRENT spec so a model
* the user already chose survives picking a template — the template owns the prompt
* and the character of the agent, never the model, which is the org's own decision.
*/
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
return {
...current,
name: t.name,
description: t.summary,
systemPrompt: t.systemPrompt,
tools: current.tools,
config: t.config ? { ...defaults, ...t.config } : undefined,
}
}
-33
View File
@@ -138,24 +138,6 @@ export type AgentBuilderLoaders = {
loadPromptBody?: (name: string) => Promise<string>
/** The live tool catalog. Rejects → typeable-only tools. */
loadTools?: () => Promise<BuilderOption[]>
/**
* Draft a spec from a plain-English description — the quickstart's "describe your
* agent" box. A model call, so it is an effect like the rest; the instruction and
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
* Absent → the quickstart still works: the description seeds the handle and the
* description field, and the user writes the prompt. Rejects → the same fallback,
* with the reason shown, so a drafting failure never blocks building an agent.
*/
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
/**
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
* The quickstart's third step — proving the thing that was just created actually
* answers, which is the only step that can prove it. Absent → the step says so and
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
* the org's balance before any inference, so an unfunded org is refused rather than
* given free compute.
*/
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
/**
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
@@ -165,21 +147,6 @@ export type AgentBuilderLoaders = {
createAgent: (body: AgentCreateBody) => Promise<unknown>
}
/**
* One recorded run, as the quickstart needs it. Deliberately the small half of the
* backend's run view: what happened, which model did it, and what came out. A
* `status` other than `ok` is a run that REALLY failed — the backend records the
* failure as a run rather than hiding it — so `error` is a fact about the execution,
* not a transport problem to guess at.
*/
export type AgentRunResult = {
status: string
model?: string
output?: string
error?: string
durationMs?: number
}
/** The reason a create failed, distinguished so the UI reacts correctly. */
export type BuilderErrorKind =
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
+28 -42
View File
@@ -14,13 +14,12 @@
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
* "create your first agent" empty state that opens the QUICKSTART — the one way to
* create an agent here — never the mockup's sample data.
* "create your first agent" empty state with the real New-Agent flow — never the
* mockup's sample data.
*
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { useAnalytics } from '@hanzo/event/react'
import { EVENTS } from '@hanzo/event'
@@ -78,9 +77,7 @@ import {
TopAgents,
VersionBadge,
} from './agents/parts'
import { AgentDetailView } from './agents/forms'
import { agentBuilderLoaders } from './agents/loaders'
import { AgentQuickstart } from '~/components/agent-builder'
import { AgentDetailView, NewAgentForm } from './agents/forms'
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
const PAGE_SIZE = 8
@@ -137,7 +134,6 @@ function useAgents() {
}
export function AgentsModule(props: { params: Record<string, string> }) {
const router = useRouter()
const detail = useDetailPane()
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
@@ -230,11 +226,26 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const analytics = useAnalytics()
// ONE way to create an agent, and it is the quickstart. This used to open the
// builder in a side pane — the same component, reached by a different shape, with
// no templates, no drafting and nowhere to run what it made. Two entrances to one
// builder is two things to keep in step; the pane was the lesser of them.
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
const openNew = useCallback(
() =>
detail.open({
title: 'New agent',
subtitle: 'Define a model, prompt, and tools',
icon: Bot,
iconColor: agentColor,
content: (
<NewAgentForm
onCancel={detail.close}
onCreated={() => {
analytics.capture(EVENTS.AGENT_CREATED)
detail.close()
void reload()
}}
/>
),
}),
[detail, agentColor, reload, analytics],
)
const header = (
<PageHeader
@@ -268,36 +279,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
/>
)
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
// ── Quickstart ──────────────────────────────────────────────────────────────
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
// purpose: building an agent does not depend on reading the ones that exist, and
// the moments you most need it — no agents yet, or the registry not answering —
// are exactly the ones those early returns would have swallowed it in.
if (routeTab === 'quickstart') {
return (
<>
<PageHeader
title="Build an agent"
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
/>
<AgentQuickstart
loaders={agentBuilderLoaders}
apiBase={config.apiUrl}
onFinished={() => {
analytics.capture(EVENTS.AGENT_CREATED)
void reload()
}}
/>
</>
)
}
// ── Initial loading ─────────────────────────────────────────────────────────
if (loading && agents.length === 0 && !error) {
return (
@@ -416,6 +397,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
// Status = health donut + agents table; Logs = the invocation activity feed.
const routeTab = props.params?.tab ?? ''
const showMetrics = routeTab === '' || routeTab === 'metrics'
const showStatus = routeTab === '' || routeTab === 'status'
const showLogs = routeTab === '' || routeTab === 'logs'
+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/CJCyAsm9Vr',
cta: 'discord.gg/CJCyAsm9Vr',
href: 'https://discord.gg/hanzo',
cta: 'discord.gg/hanzo',
},
{
icon: Linkedin,
+20 -10
View File
@@ -1,17 +1,16 @@
'use client'
/**
* Agents — the per-agent detail view, rendered inside the shared right-side
* `DetailPane`. It shows the agent's REAL facts, its REAL cost from the charged
* commerce ledger, and a best-effort activity feed from `GET /v1/agents/:name`;
* never fabricated telemetry.
* Agents — the New-Agent form and the per-agent detail view, both rendered inside
* the shared right-side `DetailPane`.
*
* CREATING an agent does not live here. It used to — a thin `NewAgentForm` adapter
* that opened the canonical `AgentBuilder` in this same pane — and that made two
* differently-shaped entrances to one builder, only one of which could offer
* templates, drafting, or anywhere to run what it made. The quickstart
* (`/agents/quickstart`) is the one way now, and the board's New-Agent button goes
* there.
* The New-Agent form is now a THIN adapter over the CANONICAL, shareable
* `AgentBuilder` (`~/components/agent-builder`) — the ONE agent builder across every
* Hanzo surface. console2 supplies its live `/v1` sources via `agentBuilderLoaders`
* (model catalog → `/v1/models`, saved prompts → `/v1/prompts`, create →
* `/v1/agents`); the builder owns the form, the LIVE model + prompt dropdowns, and
* the honest states. The detail view renders the agent's REAL facts (+ best-effort
* recent activity from `GET /v1/agents/:name`), never fabricated telemetry.
*/
import { useEffect, useState } from 'react'
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
@@ -30,6 +29,7 @@ import {
} from '~/lib/api/agents'
import { fetchUsageRecords, agentUsageFor, type AgentUsage } from '~/lib/api/aimetrics'
import { AgentBuilder } from '~/components/agent-builder'
import { agentBuilderLoaders } from './loaders'
import { StatusPill, ActivityFeed } from './parts'
const DASH = '—'
@@ -48,6 +48,16 @@ function Fact({ label, value }: { label: string; value: string }) {
)
}
/**
* The New-Agent form — the canonical `AgentBuilder` wired to console2's live `/v1`
* sources. On a successful `POST /v1/agents` it calls `onCreated` (the board
* reloads + the pane closes); a 404/unavailable backend degrades to the builder's
* own honest "not connected — create with the CLI" note.
*/
export function NewAgentForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
return <AgentBuilder loaders={agentBuilderLoaders} onCreated={onCreated} onCancel={onCancel} />
}
/**
* The per-agent detail view — REAL facts from the row, the agent's REAL cost from
* the charged commerce ledger (grouped by `metadata.agent`, NOT a hardcoded/registry
+4 -52
View File
@@ -12,9 +12,7 @@
*/
import { PlaygroundApi, PromptsApi } from '~/lib/api'
import { AgentsApi } from '~/lib/api/agents'
import { ToolsApi } from '~/lib/api/tools'
import { defaultModel, draftInstruction, parseDraft } from '~/components/agent-builder/logic'
import type { AgentBuilderLoaders, AgentSpec, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
import type { AgentBuilderLoaders, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
/** The live model catalog as builder options (id → {value,label,hint}). */
async function loadModels(): Promise<BuilderOption[]> {
@@ -22,52 +20,6 @@ async function loadModels(): Promise<BuilderOption[]> {
return ids.map((id) => ({ value: id, label: id }))
}
/**
* The org's REAL callable tools (`GET /v1/tools`) as builder options. The plane spans
* every source — connector actions, functions, zap-service routes, agents, skills and
* the org's own MCP servers — already deduplicated by name.
*
* A tool that lists but is NOT activated says so in its hint, because listing it
* silently would offer a name that resolves and then refuses at invocation time. An
* org with nothing activated gets an empty list, which is a real answer: the field
* stays typeable and the user is not shown a tool that does not exist.
*/
async function loadTools(): Promise<BuilderOption[]> {
const tools = await ToolsApi.list()
return tools.map((t) => ({
value: t.name,
label: t.name,
hint: [t.source, t.description, t.activated ? undefined : 'not activated'].filter(Boolean).join(' · ') || undefined,
}))
}
/**
* Draft a spec from a plain-English description — one real completion through the
* gateway, with the shared instruction and the shared parse (both pure, both tested).
*
* Deliberately cheap and deterministic: this is a formatting job, not a reasoning one.
* `parseDraft` drops anything it does not recognize, so a creative answer can only
* ever fill FEWER fields than asked — never inject one the builder cannot express.
*/
async function draftAgent(description: string): Promise<Partial<AgentSpec>> {
// The model comes from the LIVE catalog through the same `defaultModel` rule the
// builder's own picker uses — never a literal id here. A hardcoded one drifts
// silently the moment the catalog changes, which is exactly how `zen-omni` came to
// be named in a rule that could no longer match anything.
const model = defaultModel(await loadModels())
if (!model) throw new Error('No model is available to draft with — pick one and write the prompt yourself.')
const completion = await PlaygroundApi.chat({
model,
messages: [
{ role: 'system', content: draftInstruction() },
{ role: 'user', content: description },
],
temperature: 0.2,
})
const answer = completion?.choices?.[0]?.message?.content ?? ''
return parseDraft(answer) ?? {}
}
/** The org's saved prompts as builder rows (names; bodies fetched lazily on select). */
async function loadPrompts(): Promise<BuilderPrompt[]> {
const prompts = await PromptsApi.list()
@@ -120,8 +72,8 @@ export const agentBuilderLoaders: AgentBuilderLoaders = {
loadModels,
loadPrompts,
loadPromptBody,
loadTools,
draftAgent,
// No live tool catalog endpoint on this deployment yet — the builder's tools
// field stays typeable-only (honest), never a fabricated tool list. Wire
// `loadTools` here when `/v1/agents/tools` (or the MCP tool catalog) is bound.
createAgent: (body) => AgentsApi.create(body),
runAgent: (name, input) => AgentsApi.run(name, input),
}
@@ -1,215 +0,0 @@
'use client'
/**
* Deploy — the front door for shipping something.
*
* One section over the two things an org deploys (container apps and static
* sites) plus readings of the three planes a deploy touches (CD, CI, Storage) and
* the hosts it publishes on. The level-2 slugs come from the ONE registry
* declaration, so the sidebar rail, this strip, and ⌘K cannot disagree about
* which tabs exist.
*
* It COMPOSES; it does not re-implement. Apps come from `PaasApi`, sites from
* `PlatformSitesApi`, reconciliation from `GitopsApi`, builds from `BuildsApi`,
* buckets from `StorageApi` — the same typed clients the deeper products already
* use, over the same same-origin `/v1` bearer proxy, which resolves the org from
* the token owner server-side. No new backend path, no new credential, and no
* client-side org filter standing in for a boundary.
*/
import { useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { AppWindow, Globe, Layers, Plus, RefreshCw, Rocket } from '@hanzogui/lucide-icons-2'
import { useRouter } from 'next/navigation'
import { DataTable, MetricCard, PageHeader, PrimaryButton, StatusTag, type Column } from '@hanzo/ui/product'
import { SubNav } from '~/components/ui/SubNav'
import { productSubpageSlug } from '~/lib/products/match'
import { partialNote, summarize, type DeployKind, type DeployRow } from '~/lib/deploy/board'
import { PlatformStateCard } from '../platform/state'
import { useBoard } from './useBoard'
import { NewDeploy } from './NewDeploy'
import { CdPanel, CiPanel, DomainsPanel, StoragePanel } from './panels'
const SUBTITLE: Record<string, string> = {
'': 'Ship an app or a static site, and watch it go live.',
apps: 'Container workloads the operator reconciles for you.',
sites: 'Static builds served straight from object storage.',
}
export function DeployModule({ params }: { params: Record<string, string> }) {
const tab = productSubpageSlug('deploy', params.tab)
const board = useBoard()
const [creating, setCreating] = useState(false)
const panel = (() => {
switch (tab) {
case 'cd':
return <CdPanel />
case 'ci':
return <CiPanel />
case 'storage':
return <StoragePanel />
case 'domains':
return (
<DomainsPanel
rows={board.rows}
loading={board.loading}
incomplete={board.incomplete}
onRefresh={() => void board.reload()}
/>
)
case 'apps':
return <BoardView board={board} only="app" />
case 'sites':
return <BoardView board={board} only="site" />
default:
return <BoardView board={board} />
}
})()
return (
<>
<PageHeader
title="Deploy"
subtitle={SUBTITLE[tab] ?? 'Everything you have shipped, and the planes that carry it.'}
actions={
<XStack gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={() => void board.reload()}>
Refresh
</Button>
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={() => setCreating((c) => !c)}>
New deployment
</PrimaryButton>
</XStack>
}
/>
<SubNav id="deploy" />
{creating ? (
<NewDeploy
onCancel={() => setCreating(false)}
onDeployed={() => {
setCreating(false)
void board.reload()
}}
/>
) : null}
{panel}
</>
)
}
/** The unified board, optionally narrowed to one kind (the Apps / Sites tabs). */
function BoardView({ board, only }: { board: ReturnType<typeof useBoard>; only?: DeployKind }) {
const router = useRouter()
const rows = only ? board.rows.filter((r) => r.kind === only) : board.rows
const totals = summarize(board.rows)
const note = partialNote(board.incomplete)
// A count over a source that did not fully load is a FLOOR, not a total. Showing
// "Apps 0" beside "apps could not be loaded" states a number the board does not
// know, so the unreliable tiles read an em dash instead.
const missingApps = board.incomplete.includes('app')
const missingSites = board.incomplete.includes('site')
const count = (n: number, unreliable: boolean): string => (unreliable ? '—' : String(n))
const columns: Column<DeployRow>[] = [
{
key: 'name',
header: 'Name',
render: (r) => (
<YStack gap="$0.5" minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{r.name}
</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{r.kind === 'app' ? (r.project ? `App · ${r.project}` : 'App') : 'Static site'}
</Text>
</YStack>
),
},
{
key: 'host',
header: 'Host',
width: 260,
render: (r) =>
r.host ? (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{r.host}
</Text>
) : (
<Text fontSize="$3" color="$color10">
</Text>
),
},
{ key: 'status', header: 'Status', width: 120, render: (r) => <StatusTag status={r.status} /> },
{
key: 'health',
header: 'Health',
width: 120,
// The platform populates phase/health on a listing only while an app is
// live or deploying, so an em dash here means "not reported", not "sick".
render: (r) => (r.health ? <StatusTag status={r.health} /> : <Text fontSize="$3" color="$color10"></Text>),
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (r) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{r.updatedAt ? new Date(r.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
if (board.error) return <PlatformStateCard error={board.error} onRetry={() => void board.reload()} />
return (
<YStack gap="$3.5" data-testid={only ? `deploy-panel-${only}s` : 'deploy-board'}>
{only ? null : (
<XStack gap="$3" flexWrap="wrap">
<MetricCard
icon={<Rocket size={15} color="$color10" />}
label="Deployments"
value={count(totals.total, missingApps || missingSites)}
/>
<MetricCard
icon={<Layers size={15} color="$color10" />}
label="Live"
value={count(totals.live, missingApps || missingSites)}
/>
<MetricCard
icon={<AppWindow size={15} color="$color10" />}
label="Apps"
value={count(totals.apps, missingApps)}
/>
<MetricCard
icon={<Globe size={15} color="$color10" />}
label="Sites"
value={count(totals.sites, missingSites)}
/>
</XStack>
)}
{note ? (
<Text fontSize="$2" color="$color11" role="status">
{note}
</Text>
) : null}
<DataTable
columns={columns}
rows={rows}
loading={board.loading}
rowKey={(r) => `${r.kind}:${r.project ?? ''}:${r.slug}`}
onRowPress={(r) =>
router.push(r.kind === 'app' ? `/app-platform/${encodeURIComponent(r.slug)}` : `/platform/${encodeURIComponent(r.slug)}`)
}
empty="Nothing deployed yet — start with New deployment."
/>
</YStack>
)
}
@@ -1,304 +0,0 @@
'use client'
/**
* New deployment — pick a repo, a host, and env, then ship.
*
* Two destinations behind one form, because "deploy this repo" is one intent:
* - APP → `POST /v1/platform/projects/:project/apps` (201, `status: "draft"`)
* then `POST …/apps/:app/deploy` (202). Creating an app does NOT start
* it, so a form that stopped at 201 would report success over a thing
* that never ran. Both calls are made, and a failure names its step.
* - SITE → `POST /v1/platform/sites` (201). A static build is published by an
* upload or a git deploy afterwards, so this creates the target and the
* board shows it as `draft` until something is published to it.
*
* Env values are typed here and POSTed straight to cloud. They are never logged,
* never persisted by the browser, and never read back into this form — cloud
* masks a sealed value on read, so re-submitting what a read returned would blank
* it. That is why this form only CREATES env and has no edit mode.
*
* Secrecy is DECLARED per variable, not guessed. Every variable is sealed unless
* the person deploying marks it Public, and the form says exactly that. The
* earlier version inferred secrecy from the key NAME, which let `STRIPE_SK`,
* `GH_PAT` and `DB_PASS` through as public while the help text promised they were
* sealed — a false promise is worse than no promise, so the copy now claims only
* what THIS form decides: the default, and the mark.
*
* It deliberately does NOT promise that cloud re-seals a Public value whose shape
* looks like a credential. That server-side check is not deployed yet, and a
* safety net described before it exists is the same defect in a new place — it
* invites someone to mark a credential Public believing something downstream will
* catch it. Restore that sentence when the backend seal actually ships.
*/
import { useEffect, useMemo, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Rocket } from '@hanzogui/lucide-icons-2'
import { PaasApi, type PaasProject } from '~/lib/api/paas'
import { PlatformSitesApi, SITE_FRAMEWORKS } from '~/lib/api/platform-sites'
import { FieldRow, FieldText, FieldTextArea, PrimaryButton } from '@hanzo/ui/product'
import { FieldOptionSelect } from '~/components/ui/Field'
import {
envVars,
formError,
prunePublicKeys,
repoName,
toAppInput,
toSiteInput,
type DeployForm,
} from '~/lib/deploy/board'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from '../platform/state'
const EMPTY: DeployForm = {
kind: 'app',
name: '',
repo: '',
branch: '',
host: '',
env: '',
publicKeys: [],
framework: 'static',
}
export function NewDeploy({ onCancel, onDeployed }: { onCancel: () => void; onDeployed: () => void }) {
const [form, setForm] = useState<DeployForm>(EMPTY)
const [projects, setProjects] = useState<PaasProject[]>([])
const [project, setProject] = useState<string>('')
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<PlatformError | null>(null)
// Validation stays quiet until something has been typed — an untouched form is
// incomplete, not wrong.
const [touched, setTouched] = useState(false)
// Any edit counts as touched: the reason a disabled Deploy button is disabled
// must appear as soon as someone starts filling the form, not only once they
// happen to touch the repo field.
const set = (patch: Partial<DeployForm>) => {
setTouched(true)
setForm((f) => {
const next = { ...f, ...patch }
// Editing the env text re-derives which marks still have a variable, so a
// Public mark can never outlive the line it was made on and be inherited by
// a later variable that happens to reuse the name.
if (patch.env !== undefined) next.publicKeys = prunePublicKeys(next.env ?? '', next.publicKeys ?? [])
return next
})
}
useEffect(() => {
let live = true
PaasApi.listProjects()
.then((p) => {
if (!live) return
setProjects(p)
setProject((cur) => cur || p[0]?.slug || '')
})
.catch(() => live && setProjects([]))
return () => {
live = false
}
}, [])
const problem = useMemo(() => formError(form, project || null), [form, project])
/** The variables as they will be SENT — each carrying its sealed/public state. */
const vars = useMemo(() => envVars(form), [form])
/**
* Name (or un-name) a key as public. Only keys the person opened are listed —
* and `set` prunes that list on every env edit, so a mark cannot outlive its
* line: delete a variable and its mark goes with it, so a later variable
* reusing the name arrives sealed like any other.
*/
const markPublic = (key: string, isPublic: boolean) =>
set({
publicKeys: isPublic
? [...new Set([...(form.publicKeys ?? []), key])]
: (form.publicKeys ?? []).filter((k) => k !== key),
})
const submit = async () => {
setTouched(true)
if (problem) return
setBusy(true)
setFailure(null)
try {
if (form.kind === 'site') {
await PlatformSitesApi.create(toSiteInput(form))
} else {
// `project` is non-empty here by `formError`; that is the contract between
// the two, not an assumption about the fetch above.
const app = await PaasApi.createApp(project, toAppInput(form))
await PaasApi.deploy(project, app.slug)
}
onDeployed()
} catch (e) {
setFailure(interpretPlatformError(e))
} finally {
setBusy(false)
}
}
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3.5" data-testid="new-deploy">
<XStack items="center" gap="$2">
<Rocket size={16} color="$color10" />
<Text fontSize="$5" fontWeight="600" color="$color12">
New deployment
</Text>
</XStack>
<FieldRow label="Type">
<XStack gap="$2">
{(['app', 'site'] as const).map((k) => (
<Button
key={k}
size="$2"
bg={form.kind === k ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
aria-pressed={form.kind === k}
onPress={() => set({ kind: k })}
>
{k === 'app' ? 'App' : 'Static site'}
</Button>
))}
</XStack>
</FieldRow>
{form.kind === 'app' ? (
<FieldRow label="Project">
<FieldOptionSelect
value={project}
options={projects.map((p) => ({ value: p.slug, label: p.name || p.slug }))}
placeholder={projects.length ? 'Select a project' : 'No projects yet'}
onChange={setProject}
/>
</FieldRow>
) : null}
<FieldRow label={form.kind === 'app' ? 'Repository' : 'Repository (optional)'}>
<FieldText
value={form.repo}
placeholder="https://git.hanzo.ai/hanzoai/console.git"
onChange={(v) => {
// The name follows the repo until it is edited by hand; once the two
// differ, typing a URL stops overwriting a deliberate name.
const derived = repoName(form.repo)
set({ repo: v, ...(form.name === '' || form.name === derived ? { name: repoName(v) } : {}) })
}}
/>
</FieldRow>
<FieldRow label="Name">
<FieldText value={form.name} placeholder="console" onChange={(v) => set({ name: v })} />
</FieldRow>
<FieldRow label="Branch">
<FieldText value={form.branch ?? ''} placeholder="main" onChange={(v) => set({ branch: v })} />
</FieldRow>
{form.kind === 'site' ? (
<FieldRow label="Framework">
<FieldOptionSelect
value={form.framework ?? 'static'}
options={SITE_FRAMEWORKS.map((f) => ({ value: f, label: f }))}
onChange={(v) => set({ framework: v })}
/>
</FieldRow>
) : (
<>
<FieldRow label="Custom host">
<YStack gap="$1.5">
<FieldText
value={form.host ?? ''}
placeholder="app.example.com"
onChange={(v) => set({ host: v })}
/>
<Text fontSize="$1" color="$color10">
Optional every app is born with a host on hanzo.app. A custom host stays pending until you
prove ownership with the DNS record shown under Domains.
</Text>
</YStack>
</FieldRow>
<FieldRow label="Environment">
<YStack gap="$2">
<FieldTextArea value={form.env ?? ''} rows={4} onChange={(v) => set({ env: v })} />
<Text fontSize="$1" color="$color10">
One KEY=VALUE per line. Every variable is <Text fontWeight="600">sealed by default</Text> mark
one Public to keep it readable later. A sealed value is masked on read, so this form can set one
but never read one back.
</Text>
{vars.length ? (
<YStack
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
overflow="hidden"
data-testid="env-vars"
>
{vars.map((v, i) => (
<XStack
key={v.key}
items="center"
justify="space-between"
gap="$3"
px="$3"
py="$2"
flexWrap="wrap"
borderTopWidth={i === 0 ? 0 : 1}
borderColor="$borderColor"
>
<Text fontSize="$2" color="$color12" numberOfLines={1} className="hz-mono" flex={1} minW={0}>
{v.key}
</Text>
<XStack gap="$1.5">
{(
[
['Sealed', true],
['Public', false],
] as const
).map(([label, sealed]) => (
<Button
key={label}
size="$1"
bg={v.secret === sealed ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
aria-pressed={v.secret === sealed}
aria-label={`${v.key} ${label}`}
onPress={() => markPublic(v.key, !sealed)}
>
{label}
</Button>
))}
</XStack>
</XStack>
))}
</YStack>
) : null}
</YStack>
</FieldRow>
</>
)}
{failure ? <PlatformStateCard error={failure} /> : null}
<XStack gap="$2" items="center" flexWrap="wrap">
<PrimaryButton disabled={busy || !!problem} onPress={() => void submit()}>
{busy ? 'Deploying…' : 'Deploy'}
</PrimaryButton>
<Button disabled={busy} onPress={onCancel}>
Cancel
</Button>
{touched && problem ? (
<Text fontSize="$2" color="$color11" role="alert">
{problem}
</Text>
) : null}
</XStack>
</Card>
)
}
-384
View File
@@ -1,384 +0,0 @@
'use client'
/**
* The three neighbouring planes a deploy touches, and the domains it publishes on.
*
* Each reads the ONE canonical head for its subject — there is no `/v1/platform/cd`,
* `/v1/platform/ci`, or `/v1/platform/s3`, and inventing those aliases would give
* the estate two paths to the same data:
*
* CD → `GET /v1/deploy/applications` (`GitopsApi`). The reconciliation
* projection over the operator's App CRs — sync + health per app.
* Tenant-scoped server-side: a member sees only its own org's apps.
* CI → `GET /v1/builds` (`BuildsApi`). The native build record written by
* git push → Actions → image. There is no forge-runs endpoint.
* Storage → `GET /v1/s3/buckets` (`StorageApi`). Org-scoped object storage.
* Domains → the hosts already bound to this org's apps and sites, folded from
* the board rather than re-fetched per app.
*
* Each panel is a READING, deep-linking to the product that owns the subject for
* anything deeper. None of them duplicates that product's controls, so there is
* still exactly one place to sync a CR, browse an object, or edit a build.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, ExternalLink } from '@hanzogui/lucide-icons-2'
import { useRouter } from 'next/navigation'
import { GitopsApi, type Application } from '~/lib/api/gitops'
import { BuildsApi, type Build } from '~/lib/api/builds'
import { StorageApi, type Bucket } from '~/lib/api/storage'
import { DataTable, StatusTag, type Column } from '@hanzo/ui/product'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from '../platform/state'
import { hostRows, partialNote, type DeployKind, type DeployRow, type HostRow } from '~/lib/deploy/board'
/** Epoch ms → local string; an em dash when the backend had no timestamp. */
const when = (ms?: number): string => (ms ? new Date(ms).toLocaleString() : '—')
/** An ISO/RFC3339 string → local; the raw value when it will not parse. */
const whenIso = (iso?: string): string => {
if (!iso) return '—'
const t = Date.parse(iso)
return Number.isNaN(t) ? iso : new Date(t).toLocaleString()
}
/**
* One load of one list. Every panel here has the same shape — read a head, keep
* the rows, classify a failure honestly — so it is written once.
*/
function useList<T>(read: () => Promise<T[]>): {
rows: T[]
loading: boolean
error: PlatformError | null
reload: () => Promise<void>
} {
const [rows, setRows] = useState<T[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setRows(await read())
setError(null)
} catch (e) {
setError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
// `read` is a stable module-level call in every caller; re-running on identity
// would refetch on each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
void load()
}, [load])
return { rows, loading, error, reload: load }
}
/** Header strip shared by the panels: one sentence of context + Refresh. */
function PanelHead({ title, note, onRefresh }: { title: string; note: string; onRefresh: () => void }) {
return (
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
<YStack flex={1} minW={0} gap="$1">
<Text fontSize="$6" fontWeight="600" color="$color12">
{title}
</Text>
<Text fontSize="$2" color="$color10">
{note}
</Text>
</YStack>
<Button size="$2" icon={<RefreshCw size={15} />} onPress={onRefresh}>
Refresh
</Button>
</XStack>
)
}
// ── CD ───────────────────────────────────────────────────────────────────────
export function CdPanel() {
const { rows, loading, error, reload } = useList<Application>(GitopsApi.applications)
const columns: Column<Application>[] = [
{
key: 'name',
header: 'Application',
render: (a) => (
<YStack gap="$0.5" minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{a.name}
</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{a.namespace}
</Text>
</YStack>
),
},
{ key: 'sync', header: 'Sync', width: 120, render: (a) => <StatusTag status={a.sync} /> },
{ key: 'health', header: 'Health', width: 120, render: (a) => <StatusTag status={a.health} /> },
{
key: 'tag',
header: 'Declared → running',
width: 220,
mono: true,
render: (a) => (
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
{a.image.tag || '—'}
{a.liveTag && a.liveTag !== a.image.tag ? `${a.liveTag}` : ''}
</Text>
),
},
{
key: 'replicas',
header: 'Ready',
width: 90,
align: 'right',
render: (a) => (
<Text fontSize="$3" color="$color11" className="hz-mono">
{a.readyReplicas}/{a.replicas}
</Text>
),
},
]
return (
<YStack gap="$3.5" data-testid="deploy-panel-cd">
<PanelHead
title="CD"
note="Reconciliation of your apps, read from the operator's App CRs — the same plane cd.hanzo.ai serves."
onRefresh={() => void reload()}
/>
{error ? (
<PlatformStateCard error={error} onRetry={() => void reload()} />
) : (
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(a) => `${a.namespace}/${a.name}`}
empty="Nothing reconciled here yet."
/>
)}
</YStack>
)
}
// ── CI ───────────────────────────────────────────────────────────────────────
export function CiPanel() {
const { rows, loading, error, reload } = useList<Build>(BuildsApi.list)
const columns: Column<Build>[] = [
{
key: 'repo',
header: 'Repository',
render: (b) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{b.repo || b.id || '—'}
</Text>
),
},
{
key: 'commit',
header: 'Commit',
width: 130,
mono: true,
render: (b) => (
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
{b.commit ? b.commit.slice(0, 12) : '—'}
</Text>
),
},
{
key: 'tag',
header: 'Tag',
width: 150,
mono: true,
render: (b) => (
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
{b.tag || '—'}
</Text>
),
},
{ key: 'status', header: 'Status', width: 120, render: (b) => <StatusTag status={b.status || 'unknown'} /> },
{
key: 'startedAt',
header: 'Started',
width: 190,
render: (b) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{whenIso(b.startedAt)}
</Text>
),
},
{
key: 'duration',
header: 'Took',
width: 90,
align: 'right',
render: (b) => (
<Text fontSize="$3" color="$color11">
{b.duration || '—'}
</Text>
),
},
]
return (
<YStack gap="$3.5" data-testid="deploy-panel-ci">
<PanelHead
title="CI"
note="Builds your pushes produced — commit to image, on the native runners."
onRefresh={() => void reload()}
/>
{error ? (
<PlatformStateCard error={error} onRetry={() => void reload()} />
) : (
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(b) => b.id || `${b.repo}@${b.commit}`}
empty="No builds recorded yet."
/>
)}
</YStack>
)
}
// ── Storage ──────────────────────────────────────────────────────────────────
export function StoragePanel() {
const router = useRouter()
const { rows, loading, error, reload } = useList<Bucket>(StorageApi.buckets)
const columns: Column<Bucket>[] = [
{
key: 'name',
header: 'Bucket',
render: (b) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{b.name}
</Text>
),
},
{
key: 'createdAt',
header: 'Created',
width: 200,
// The backend carries Unix SECONDS here, unlike the ms timestamps the
// platform rows use.
render: (b) => (
<Text fontSize="$3" color="$color11">
{when(b.createdAt ? b.createdAt * 1000 : undefined)}
</Text>
),
},
]
return (
<YStack gap="$3.5" data-testid="deploy-panel-storage">
<PanelHead
title="Storage"
note="Object storage in your org — where a static site's build is served from."
onRefresh={() => void reload()}
/>
{error ? (
<PlatformStateCard error={error} onRetry={() => void reload()} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(b) => b.name}
onRowPress={(b) => router.push(`/s3?bucket=${encodeURIComponent(b.name)}`)}
empty="No buckets yet."
/>
<Button size="$2" self="flex-start" icon={<ExternalLink size={15} />} onPress={() => router.push('/s3')}>
Open the file manager
</Button>
</>
)}
</YStack>
)
}
// ── Domains ──────────────────────────────────────────────────────────────────
export function DomainsPanel({
rows,
loading,
incomplete,
onRefresh,
}: {
rows: DeployRow[]
loading: boolean
/** Sources the board could not fully read — this list inherits their gaps. */
incomplete: DeployKind[]
onRefresh: () => void
}) {
const hosts = hostRows(rows)
// The host list is derived from the board's rows, so a half-loaded board is a
// half-loaded domain list. Saying so matters more here than anywhere else: a
// missing host reads as "nothing is bound", which is the opposite of the truth.
const note = partialNote(incomplete)
const columns: Column<HostRow>[] = [
{
key: 'host',
header: 'Host',
render: (h) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{h.host}
</Text>
),
},
{
key: 'owner',
header: 'Serves',
width: 220,
render: (h) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{h.owner}
</Text>
),
},
{
key: 'kind',
header: 'Type',
width: 110,
render: (h) => (
<Text fontSize="$3" color="$color11">
{h.kind === 'app' ? 'App' : 'Site'}
</Text>
),
},
{ key: 'status', header: 'Status', width: 120, render: (h) => <StatusTag status={h.status} /> },
]
return (
<YStack gap="$3.5" data-testid="deploy-panel-domains">
<PanelHead
title="Domains"
note="Every host serving one of your deployments. Bind a custom host when you deploy; it stays pending until DNS proves ownership."
onRefresh={onRefresh}
/>
{note ? (
<Text fontSize="$2" color="$color11" role="status">
{note}
</Text>
) : null}
<DataTable
columns={columns}
rows={hosts}
loading={loading}
rowKey={(h) => h.host}
empty="No hosts bound yet."
/>
</YStack>
)
}
@@ -1,86 +0,0 @@
/**
* The Deploy front door's catalog declaration, pinned against the real source.
*
* `registry.tsx` cannot be imported here — it pulls the icon ESM the runner can't
* load — so the house pattern applies (see lib/products/sentry-scope.test.ts):
* read the entry's own flags off the SOURCE, and prove the predicates that consume
* them with fixtures elsewhere (`match-core.test.ts` owns the routing algorithm).
* A fixture proves the predicate; only the source proves the DATA, and the data is
* what a later edit breaks.
*
* Each fact here has already been a bug shape in this console:
* - a `deploy` SLUG_ALIAS makes the front door unreachable, because
* `canonicalSlug` rewrites the head before any lookup happens;
* - `admin: true` on a customer surface hides it from every customer;
* - two entries labelled "Deploy" put identically-named rows in one nav section,
* one of them the admin ESTATE map;
* - a sub-page with no `:tab` route renders an honest stub, and nothing fails.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { SLUG_ALIASES } from '~/lib/products/match-core'
const SRC = readFileSync(join(__dirname, '..', '..', '..', 'lib', 'products', 'registry.tsx'), 'utf8')
/** One catalog entry's source text: from its `id:` to the next entry's. */
function entrySrc(id: string): string {
const at = SRC.indexOf(`id: '${id}',`)
expect(at, `catalog entry '${id}' not found in registry.tsx`).toBeGreaterThan(-1)
const next = SRC.indexOf("\n id: '", at + 1)
return SRC.slice(at, next === -1 ? SRC.length : next)
}
describe('the Deploy product', () => {
const src = entrySrc('deploy')
it('is a Platform module labelled Deploy', () => {
expect(src).toMatch(/^\s*label: 'Deploy',\s*$/m)
expect(src).toMatch(/^\s*category: 'Platform',\s*$/m)
expect(src).toMatch(/^\s*kind: 'module',\s*$/m)
})
it('is NOT admin-gated — a customer ships their own code', () => {
expect(/^\s*admin:\s*true,\s*$/m.test(src)).toBe(false)
})
it('declares the index and the :tab route its sub-pages need', () => {
expect(src).toMatch(/\{ path: '', component: DeployModule \}/)
expect(src).toMatch(/\{ path: ':tab', component: DeployModule \}/)
})
it('declares exactly the six sub-pages the module dispatches on', () => {
const slugs = [...src.matchAll(/\{ slug: '([a-z]+)', label:/g)].map((m) => m[1])
expect(slugs).toEqual(['apps', 'sites', 'domains', 'cd', 'ci', 'storage'])
})
it('is the ONE entry labelled Deploy in the whole catalog', () => {
expect([...SRC.matchAll(/^\s*label: 'Deploy',\s*$/gm)]).toHaveLength(1)
})
})
describe('/deploy resolves to the front door', () => {
it('has no alias shadowing the head', () => {
// `canonicalSlug` rewrites the FIRST segment unconditionally, so an alias
// named `deploy` would route the front door's own URL somewhere else.
expect(SLUG_ALIASES.deploy).toBeUndefined()
})
it('leaves App Platform addressable under its own id', () => {
expect(SLUG_ALIASES['app-platform']).toBeUndefined()
expect(entrySrc('app-platform')).toMatch(/^\s*label: 'App Platform',\s*$/m)
})
})
describe('the estate fleet map stays admin-only, under its own name', () => {
const src = entrySrc('gitops')
it('is labelled Fleet, not Deploy', () => {
expect(src).toMatch(/^\s*label: 'Fleet',\s*$/m)
})
it('is still admin-gated — it shows every org, not just yours', () => {
expect(/^\s*admin:\s*true,\s*$/m.test(src)).toBe(true)
})
})
@@ -1,95 +0,0 @@
'use client'
/**
* The deploy board's ONE read: the org's container apps and its static sites,
* folded into a single `DeployRow` list.
*
* Two independent backends answer here (`/v1/platform/projects/:p/apps` and
* `/v1/platform/sites`), so they are read with `allSettled` and reported
* separately. A partial read stays a partial read: the rows that loaded render,
* and `incomplete` names the source that did not, so a caller can say which
* number on the screen is now a lie. Collapsing that into one error would hide
* working data; collapsing it into silence would show a short list as if it were
* the whole truth. Only when BOTH fail is there nothing honest to draw, and then
* `error` carries the state card.
*
* The apps fan-out is done HERE rather than through `PaasApi.listAllApps`, which
* swallows a per-project failure with `.catch(() => [])`. That is a fine default
* for a board that only wants rows, but it makes "you have 3 apps" indistinguishable
* from "you have 3 apps that we could see", and this board's whole job is to say
* which of the two it is showing.
*
* Org scoping is the bearer proxy's job on the server side. This hook sends no
* org and filters by none.
*/
import { useCallback, useEffect, useState } from 'react'
import { PaasApi } from '~/lib/api/paas'
import { PlatformSitesApi } from '~/lib/api/platform-sites'
import { byRecency, rowOfApp, rowOfSite, type DeployKind, type DeployRow } from '~/lib/deploy/board'
import { interpretPlatformError, type PlatformError } from '../platform/state'
export type Board = {
rows: DeployRow[]
loading: boolean
/** Set only when BOTH sources failed — there is nothing truthful to render. */
error: PlatformError | null
/** Sources whose rows are missing or partial. Empty when the board is whole. */
incomplete: DeployKind[]
reload: () => Promise<void>
}
/** Rows for every project the org owns, and whether every project answered. */
async function readApps(): Promise<{ rows: DeployRow[]; whole: boolean }> {
// A failure HERE is total — no project list means no apps at all — so it
// propagates and the caller marks the whole source missing.
const projects = await PaasApi.listProjects()
const settled = await Promise.allSettled(
projects.map((project) =>
PaasApi.listApps(project.slug || project.id).then((apps) => apps.map((app) => rowOfApp({ ...app, project }))),
),
)
return {
rows: settled.flatMap((s) => (s.status === 'fulfilled' ? s.value : [])),
whole: settled.every((s) => s.status === 'fulfilled'),
}
}
export function useBoard(): Board {
const [rows, setRows] = useState<DeployRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<PlatformError | null>(null)
const [incomplete, setIncomplete] = useState<DeployKind[]>([])
const load = useCallback(async () => {
setLoading(true)
const [apps, sites] = await Promise.allSettled([readApps(), PlatformSitesApi.list()])
const next: DeployRow[] = []
if (apps.status === 'fulfilled') next.push(...apps.value.rows)
if (sites.status === 'fulfilled') next.push(...sites.value.map(rowOfSite))
setRows(byRecency(next))
if (apps.status === 'rejected' && sites.status === 'rejected') {
setError(interpretPlatformError(apps.reason))
setIncomplete([])
setLoading(false)
return
}
setError(null)
setIncomplete([
// Rejected = the source is missing entirely; fulfilled-but-not-whole = some
// projects answered and some did not. Both make the app counts a floor.
...(apps.status === 'rejected' || !apps.value.whole ? (['app'] as const) : []),
...(sites.status === 'rejected' ? (['site'] as const) : []),
])
setLoading(false)
}, [])
useEffect(() => {
void load()
}, [load])
return { rows, loading, error, incomplete, reload: load }
}
@@ -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/CJCyAsm9Vr')} />
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<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/CJCyAsm9Vr')} />
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
</YStack>
</LandingCard>
+1 -10
View File
@@ -13,7 +13,7 @@ import { CheckCircle2, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
export type PlatformErrorKind = 'not-configured' | 'forbidden' | 'payment' | 'unavailable' | 'error'
export type PlatformErrorKind = 'not-configured' | 'forbidden' | 'unavailable' | 'error'
export type PlatformError = { kind: PlatformErrorKind; message: string }
@@ -27,12 +27,6 @@ export function interpretPlatformError(e: unknown): PlatformError {
// the infra PAAS_SERVICE_TOKEN message (that would be a false claim to a customer).
if (status === 501) return { kind: 'not-configured', message }
if (status === 401 || status === 403) return { kind: 'forbidden', message }
// 402 = the spend gate refused: no active subscription and no prepaid credit, or
// a spend cap was reached. The control plane is reachable and the caller is
// authorized — it is a BILLING answer, so it must not read as an outage. The
// backend's own sentence already names the cure ("Add credits at …"), so it is
// shown verbatim rather than replaced with a guess about which cure applies.
if (status === 402) return { kind: 'payment', message }
if (status === 404) return { kind: 'unavailable', message }
// 503 = the route is mounted but its runtime/dependency is not configured on
// THIS deployment (e.g. zt networking fail-closed until ZT_CLIENT_* is set).
@@ -46,7 +40,6 @@ export function interpretPlatformError(e: unknown): PlatformError {
const TITLES: Record<PlatformErrorKind, string> = {
'not-configured': 'PaaS control plane not configured',
forbidden: 'Connected · managed by Hanzo',
payment: 'Billing required',
unavailable: 'Endpoint not served here',
error: 'Could not reach the platform',
}
@@ -58,8 +51,6 @@ const BODIES: Record<PlatformErrorKind, string> = {
'Your workloads run on managed Hanzo Cloud — no cluster to operate. The full control-plane fleet view (clusters, nodes, raw workloads) is an admin surface; deploy and scale through Functions, Agents, and the platform.',
unavailable:
'The platform backend on this deployment does not serve this endpoint (it ships as a separate service). This view reads live data wherever the endpoint is served; nothing is fabricated here.',
// Empty → the card shows the backend's own sentence, which names the cure.
payment: '',
error: '',
}
@@ -12,7 +12,7 @@ export function ModelSelect({
ids,
onChange,
disabled,
placeholder = 'model id, e.g. zen5-mini',
placeholder = 'model id, e.g. zen-omni',
}: {
value: string
ids: string[]
@@ -1,37 +0,0 @@
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)
})
})
+8 -15
View File
@@ -3,15 +3,8 @@
*
* 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.
*
* 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.
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
* exactly like the mockup ("Explain quantum computing · zen-omni").
*/
export type Example = {
id: string
@@ -27,42 +20,42 @@ export const EXAMPLES: Example[] = [
{
id: 'quantum',
label: 'Explain quantum computing',
model: 'zen5-mini',
model: 'zen-omni',
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: 'zen5-coder',
model: 'zen-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: 'zen5-mini',
model: 'zen-omni',
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: 'zen5-mini',
model: 'zen-omni',
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: 'zen5',
model: 'zen-omni',
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: 'zen5-coder',
model: 'zen-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.',
},
+3 -3
View File
@@ -4,8 +4,8 @@
* SubNav — the ONE level-2 nav for a product.
*
* Clicking into a product reveals ITS options rather than replacing the screen.
* That second level lives in the SIDEBAR (`SubRows`), expanded beneath the product's
* own row — the house pattern. This strip is the SAME nav, rendered from
* That second level lives in the SIDEBAR (`DrillNav`) — the house pattern, the one
* the whole console already drills with. This strip is the SAME nav, rendered from
* the SAME source (`productSubpages` over the registry), for the viewports where
* the sidebar is not on screen: below `lg` the sidebar is a drawer, so the strip
* carries level 2 in the content column and hides itself at `lg+` where the rail
@@ -75,7 +75,7 @@ export function SubNav({
const to = href ?? ((slug: string) => subpageHref(id, slug))
return (
// Hidden at lg+ — the sidebar's SubRows is the level-2 nav there. Purely a
// Hidden at lg+ — the sidebar's DrillNav is the level-2 nav there. Purely a
// CSS media style prop (not a JS media branch), so SSR and first paint match.
<XStack
gap="$1.5"
-10
View File
@@ -50,15 +50,6 @@ export type ConsoleConfig = {
brandName: string
/** Unified cloud backend base URL (hanzoai/cloud /v1) — shared across brands. */
cloudUrl: string
/**
* The PUBLIC gated API host — what a customer's own code calls, and the only base
* that belongs in a copyable snippet. Distinct from `cloudUrl`, which is SAME-ORIGIN
* in the browser: printing that would hand someone `https://console.<brand>/v1/…`,
* a URL that works for the SPA's proxied session and not for their API key. Shared
* across brands, because the cloud backend is one multi-tenant `/v1` scoped by the
* brand JWT's org — there is no per-brand API host to resolve.
*/
apiUrl: string
/** PaaS base URL (DOKS cluster control plane) — shared. */
platformUrl: string
/**
@@ -478,7 +469,6 @@ export function resolveConfig(host: string = currentHost()): ConsoleConfig {
brand,
brandName: b.brandName,
cloudUrl: cloudUrl(),
apiUrl: trimSlash(process.env.NEXT_PUBLIC_API_URL ?? 'https://api.hanzo.ai'),
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? b.iamUrl),
iamOrgName: process.env.NEXT_PUBLIC_IAM_ORG_NAME ?? org,
iamAppName: process.env.NEXT_PUBLIC_IAM_APP_NAME ?? app,
+121 -127
View File
@@ -1,27 +1,19 @@
'use client'
/**
* Dashboard shell a TWO-LEVEL sidebar (products, each expanding its own sub-pages
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
* Dashboard shell a TWO-LEVEL sidebar (product list drill into a product) + top
* bar + content, responsive across phone / tablet / laptop / desktop.
*
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
* Pinned section the user curates, then every product grouped by category. Each
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
* (nothing auto-collapses); the header is flush-left with the top-level items and
* carries an OPTIONAL collapse chevron whose state persists per-user.
*
* Level 2 a product's sub-pages (Overview + specifics + the uniform base set:
* Settings · Status · Logs · Metrics) expands BENEATH that product's own row, so
* its options appear without the rest of the catalog going away. The label
* navigates; the chevron beside it only expands or collapses, and that choice
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
*
* This replaced a DRILL: clicking a product used to swap the whole rail for that
* product's sub-nav, behind a "Back to all products" button. The options were the
* same either way what the drill took away was every OTHER product, which is
* precisely what someone needs when the reason they opened the rail was to go
* somewhere else.
* carries an OPTIONAL collapse chevron whose state persists per-user. Clicking a
* PRODUCT that has sub-pages DRILLS the sidebar INTO that product's sub-nav
* (Overview + specifics + the uniform base set: Settings · Status · Logs · Metrics)
* with a clear BACK affordance to the full list Level 2. A product with only an
* Overview navigates directly (no drill). Sub-pages with no backend yet are dimmed
* and open an honest placeholder (never a dead link).
*
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
@@ -50,10 +42,11 @@
* NOT a JS media branch, so SSR and first paint match. The nav body (`SidebarNav`) is
* shared by the sidebar, the flyout, and the drawer (DRY) one definition, many mounts.
*/
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import {
ArrowLeft,
BarChart3,
Bell,
BookOpen,
@@ -68,6 +61,7 @@ import {
Lock,
Menu,
PanelLeft,
Plus,
Repeat,
ScrollText,
Search,
@@ -95,17 +89,9 @@ import { entryMatches } from '~/lib/products/search'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { orderEntries } from '~/lib/products/order'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
EMPTY_OPEN,
type CategoryOpen,
} from '~/lib/products/nav-accordion'
import { categoryIsOpen, toggleCategory, NAV_OPEN_PREF, EMPTY_OPEN, type CategoryOpen } from '~/lib/products/nav-accordion'
import { usePreferences } from '~/lib/products/preferences'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { useEntitlements } from '~/lib/entitlements-context'
import { AddProductPanel } from '~/components/AddProductPanel'
@@ -210,18 +196,14 @@ function FixedRow({
)
}
/** A level-1 catalog row colored product icon, opens the product; trailing is an
* expansion chevron (products with sub-pages) then a pin star (catalog rows) or a
* color/customize dot (pinned rows). */
/** A level-1 catalog row colored product icon, opens the product; trailing is a
* pin star (catalog rows) or a color/customize dot (pinned rows). */
function NavRow({
entry,
active,
color,
collapsed,
pinned,
expandable,
expanded,
onExpand,
onOpen,
onToggle,
onCustomize,
@@ -231,10 +213,6 @@ function NavRow({
color: string
collapsed: boolean
pinned?: boolean
/** True when the product has sub-pages to expand beneath it. */
expandable?: boolean
expanded?: boolean
onExpand?: () => void
onOpen: () => void
onToggle?: () => void
onCustomize?: () => void
@@ -272,27 +250,6 @@ function NavRow({
>
{entry.label}
</Button>
{/* Expansion is its OWN control, separate from the row: the label navigates,
the chevron only opens or closes. One target that did both would make
"show me what's in here" and "take me there" the same gesture. */}
{expandable && onExpand ? (
<Button
size="$2"
chromeless
onPress={onExpand}
aria-expanded={expanded}
aria-controls={`nav-sub-${entry.id}`}
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${entry.label}`}
icon={
<span
className="hz-chevron"
style={{ display: 'inline-flex', transform: expanded ? 'rotate(90deg)' : undefined }}
>
<ChevronRight size={14} color="$color9" />
</span>
}
/>
) : null}
{onCustomize ? (
<ColorDot color={color} onPress={onCustomize} label={`Customize ${entry.label}`} />
) : onToggle ? (
@@ -310,32 +267,58 @@ function NavRow({
}
/**
* Level 2 a product's sub-pages, expanded IN PLACE beneath its own row. The list
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
*
* Indented under the product and hung on a hairline, so the nesting is legible
* without a second heading the product's own row above IS the heading. Collapsed,
* the rows are `inert`, so hidden options leave the tab order.
* Level 2 the drilled product's sub-nav. Reached by clicking a product with
* sub-pages; a BACK affordance returns to the full product list (Level 1). The
* header shows the product (colored icon + name) under a category breadcrumb; the
* body is `productSubpages(entry)` Overview + specifics + the uniform base set
* with an unwired sub-page dimmed but honest (opens a placeholder, never a dead link).
*/
function SubRows({
function DrillNav({
entry,
subs,
pathname,
open,
color,
onBack,
onGo,
}: {
entry: CatalogEntry
subs: ProductSubpage[]
pathname: string
open: boolean
color: string
onBack: () => void
onGo: (path: string) => void
}) {
const activeSlug = activeSubpage(pathname, entry.id)
const Icon = entry.icon
return (
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
<div className="hz-acc-inner">
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
<>
{/* Back to the full product list, with the category as a quiet breadcrumb. */}
<Button
chromeless
size="$2"
height={34}
px="$2"
justify="flex-start"
icon={<ArrowLeft size={17} />}
onPress={onBack}
hoverStyle={{ bg: '$color3' }}
aria-label="Back to all products"
>
<Text fontSize="$1" color="$color10" fontWeight="500">
{entry.category}
</Text>
</Button>
{/* The product header. */}
<XStack items="center" gap="$2.5" px="$2" py="$1.5" mb="$1">
<ProductIcon icon={Icon} color={color} size={22} />
<Text flex={1} fontSize="$5" fontWeight="800" color="$color12" numberOfLines={1}>
{entry.label}
</Text>
</XStack>
<ScrollView flex={1} minH={0}>
<YStack gap="$0.5">
{subs.map((sp) => {
const wired = subpageWired(entry.id, sp.slug)
const active = sp.slug === activeSlug
@@ -346,9 +329,9 @@ function SubRows({
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
bg={active ? '$color4' : 'transparent'}
justify="flex-start"
icon={<SubIcon size={15} />}
icon={<SubIcon size={17} />}
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
size="$2"
size="$3"
opacity={wired ? 1 : 0.6}
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
>
@@ -357,8 +340,8 @@ function SubRows({
)
})}
</YStack>
</div>
</div>
</ScrollView>
</>
)
}
@@ -486,23 +469,31 @@ function SidebarNav({
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
// ── Level 2, in place ─────────────────────────────────────────────────────
// A product's sub-pages expand beneath its own row; nothing replaces the list.
// ── Drill-in state (Level 1 ⇄ Level 2) ────────────────────────────────────
const activeId = activeModuleId(pathname)
const activeEntry = activeId ? findEntry(activeId) ?? null : null
const activeSubs = useMemo(
() => (activeEntry && activeEntry.kind === 'module' ? productSubpages(activeEntry, showAdmin) : []),
[activeEntry, showAdmin],
)
// `manualList` = the user hit BACK — force the Level-1 list even though the active
// route is a drillable product. Entering a DIFFERENT product resets it (auto-drill).
const [manualList, setManualList] = useState(false)
useEffect(() => {
setManualList(false)
}, [activeId])
const canDrill = Boolean(activeEntry) && activeSubs.length > 1
const drilled = canDrill && !manualList && !collapsed
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
const toggleExpand = (id: string) =>
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
const go = (path: string) => {
router.push(path)
onNavigate()
}
// Open a product from the list. One with sub-pages keeps the drawer open, because
// becoming active expands it in place and its options are the next thing to read;
// a leaf navigates and closes. An external launch tile opens its deployed app in a
// new tab.
// Open a product from the list: DRILL if it has sub-pages (keep the drawer open so
// the sub-nav shows), else navigate directly (leaf → close the drawer). An external
// launch tile opens its deployed app in a new tab.
const open = (entry: CatalogEntry) => {
if (entry.kind === 'external') {
openProduct(entry, go)
@@ -512,48 +503,12 @@ function SidebarNav({
setFilter('')
const subs = productSubpages(entry, showAdmin)
if (subs.length > 1) {
router.push(`/${entry.id}`) // its sub-pages open beneath it
setManualList(false)
router.push(`/${entry.id}`) // DRILL — keep the drawer open for the sub-nav
} else {
go(`/${entry.id}`) // leaf — navigate + close
}
}
/**
* ONE product row the row itself plus, for a product that has them, its sub-pages
* expanded beneath. Both the Pinned group and the category groups render through
* this, so a product looks and behaves identically wherever it appears.
*/
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
const subs = productSubpages(entry, showAdmin)
// A pinned product appears TWICE — once under Pinned, once in its category — and
// only ONE of those may carry the sub-pages. Two copies of the same list is two
// navs painting at once, which is the very thing this rail exists to avoid, and
// it doubles the rail's height for no information. The PINNED copy owns it: the
// user put it up there, and it is the one they read first.
const owns = opts.pinned || !isPinned(entry.id)
const expandable = owns && entry.kind === 'module' && subs.length > 1
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
return (
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
<NavRow
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={opts.pinned ?? isPinned(entry.id)}
expandable={expandable}
expanded={expanded}
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
onOpen={() => open(entry)}
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
onCustomize={opts.pinned ? () => customize(entry) : undefined}
/>
{expandable ? (
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
) : null}
</YStack>
)
}
const openDocs = () => {
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
onNavigate()
@@ -725,9 +680,26 @@ function SidebarNav({
)
}
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
// in place; All-products; identity + wallet. ──
// ── Level 2 — drilled into a product's sub-nav, with a BACK affordance ──
if (drilled && activeEntry) {
return (
<>
<DrillNav
entry={activeEntry}
subs={activeSubs}
pathname={pathname}
color={colorOf(activeEntry.id)}
onBack={() => setManualList(true)}
onGo={go}
/>
<SidebarAccount collapsed={false} />
<SidebarWallet collapsed={false} />
</>
)
}
// ── Level 1 — the full product list: brand; filter; Overview/Docs; Pinned; every
// category (EXPANDED by default, collapsible); All-products; identity + wallet. ──
return (
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
// screen-reader user had no way to jump to the product list and no way to
@@ -805,7 +777,18 @@ function SidebarNav({
{group.entries.map((e) => {
const entry = findEntry(e.id)
if (!entry) return null
return productRow(entry, { pinned: true })
return (
<NavRow
key={`pin-${e.id}`}
entry={entry}
active={isActive(e.id)}
color={colorOf(e.id)}
collapsed={false}
pinned
onOpen={() => open(entry)}
onCustomize={() => customize(entry)}
/>
)
})}
</YStack>
))}
@@ -820,7 +803,18 @@ function SidebarNav({
open={categoryIsOpen(navOpen, group.category, { filtering })}
onToggle={() => toggleSection(group.category)}
>
{group.entries.map((entry) => productRow(entry))}
{group.entries.map((entry) => (
<NavRow
key={entry.id}
entry={entry}
active={isActive(entry.id)}
color={colorOf(entry.id)}
collapsed={false}
pinned={isPinned(entry.id)}
onOpen={() => open(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
</CategorySection>
))}
-47
View File
@@ -531,40 +531,6 @@ export type NewAgentBody = {
config?: Partial<AgentConfig>
}
/**
* One recorded run of an agent what `POST /v1/agents/:ref/run` answers with.
*
* Every run this carries reflects an execution that ACTUALLY happened: the backend
* records a model failure as a run with `status: "error"` and its message, and
* answers 502 with that same run as the body. So a caller reads the run either way,
* and a failure is a fact about the run rather than a transport error to guess at.
*/
export type AgentRun = {
id: string
/** `ok` when the completion came back, `error` when it did not. */
status: string
/** The model actually used — a failover run reports the one it fell over to. */
model: string
/** The completion, when there was one. */
output?: string
/** The failure, when there was one. */
error?: string
durationMs?: number
}
/** Normalize a run payload defensively — a renamed field degrades, never throws. */
export function normalizeRun(payload: unknown): AgentRun {
const r = asRecord(payload)
return {
id: str(r.id) ?? '',
status: str(r.status) ?? '',
model: str(r.model) ?? '',
output: str(r.output),
error: str(r.error),
durationMs: num(r.durationMs),
}
}
export const AgentsApi = {
/** The agent registry (`GET /v1/agents`). Honest-empty/error until bound. */
list: (): Promise<Agent[]> => restGet<unknown>(originV1Url(BASE)).then(normalizeAgents),
@@ -585,19 +551,6 @@ export const AgentsApi = {
/** Create an agent (`POST /v1/agents`) — only called when the backend is live. */
create: (body: NewAgentBody): Promise<unknown> => restPost<unknown>(originV1Url(BASE), body),
/**
* Run an agent once (`POST /v1/agents/:ref/run`) and get the recorded run back.
* `ref` is the agent's name or its `agent_…` id either resolves the same agent.
*
* THIS MOVES MONEY: the backend authorizes the org's balance BEFORE any inference,
* so an unfunded org gets 402 and no free compute. A model failure answers 502 with
* the RUN as the body, whose `error` field the transport surfaces as the thrown
* message so a caller that shows `e.message` is already showing the run's own
* reason, not a generic transport failure.
*/
run: (ref: string, input: string): Promise<AgentRun> =>
restPost<unknown>(originV1Url(`${agentPath(ref)}/run`), { input }).then(normalizeRun),
/** Delete an agent (`DELETE /v1/agents/:name`) keyed by the agent's NAME, never the
* display `id`. Only called when the backend is live. */
remove: (name: string): Promise<void> => restDelete(originV1Url(agentPath(name))),
-77
View File
@@ -1,77 +0,0 @@
/**
* Tools API the unified tool plane (`GET /v1/tools`).
*
* ONE flat set of callable names spanning every source the org can reach
* connector actions, user functions, zap-service routes, agents, skills, and the
* org's own external MCP servers deduplicated by name with source precedence
* applied server-side. It LISTS; dispatch is `POST /v1/tools/call` and belongs to
* whatever runs the agent, not to a form.
*
* Called SAME-ORIGIN with no prefix (`originV1Url('tools')`), so it rides the
* console's own user-bearer proxy: a short-lived user-bound IAM token is minted
* server-side and the cloud plane scopes the listing to the caller's org and
* project. No credential reaches the browser.
*
* An org with nothing activated gets `{"tools":[]}` a REAL empty answer, not a
* failure. The agent builder shows that honestly (the field stays typeable) rather
* than inventing a tool that would 404 on the first invocation.
*/
import { restGet, originV1Url } from './client'
/** Where a tool came from — the plane dedupes across these by name. */
export type ToolSource = 'connector' | 'function' | 'zap-service' | 'agent' | 'skill' | 'mcp'
/** One callable tool as discovery reports it. */
export type Tool = {
/** The dispatch name — exactly what an agent's `tools` list must carry. */
name: string
/** Which plane provides it. */
source?: ToolSource
/** One line on what it does, when the provider carries one. */
description?: string
/** Whether it is activated for this org+project (an inactive tool lists but won't run). */
activated: boolean
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
/**
* Defensive normalizer. The listing is read from whichever envelope key the plane
* uses, and a row missing `name` is dropped a nameless tool cannot be dispatched,
* so surfacing it would offer the user something that cannot work.
*/
export function normalizeTools(payload: unknown): Tool[] {
const rows = Array.isArray(payload)
? payload
: payload && typeof payload === 'object'
? ((payload as Record<string, unknown>).tools ??
(payload as Record<string, unknown>).data ??
(payload as Record<string, unknown>).items)
: undefined
if (!Array.isArray(rows)) return []
const out: Tool[] = []
for (const raw of rows) {
if (!raw || typeof raw !== 'object') continue
const r = raw as Record<string, unknown>
const name = str(r.name)
if (!name) continue
out.push({
name,
source: str(r.source) as ToolSource | undefined,
description: str(r.description),
activated: r.activated === true,
})
}
return out
}
/** The tool plane's read side. */
export const ToolsApi = {
/**
* Every tool the caller's org and project can reach, each flagged `activated`.
* `activatedOnly` narrows to the callable set the plane tests the raw query
* against the literal string "true", so this sends that or nothing at all.
*/
list: (activatedOnly = false): Promise<Tool[]> =>
restGet<unknown>(originV1Url(activatedOnly ? 'tools?activated=true' : 'tools')).then(normalizeTools),
}
-388
View File
@@ -1,388 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
rowOfApp,
rowOfSite,
primaryHost,
boundHosts,
hostOf,
byRecency,
summarize,
repoName,
envVars,
prunePublicKeys,
partialNote,
parseEnv,
hostError,
envError,
toAppInput,
toSiteInput,
formError,
hostRows,
type DeployForm,
} from './board'
import type { PaasAppWithProject } from '~/lib/api/paas'
import type { Site } from '~/lib/api/platform-sites'
const app = (over: Partial<PaasAppWithProject> = {}): PaasAppWithProject =>
({
id: 'a1',
org: 'acme',
projectId: 'p1',
slug: 'api',
name: 'API',
status: 'live',
domains: ['api.acme.hanzo.app'],
updatedAt: 200,
project: { id: 'p1', org: 'acme', slug: 'web', name: 'Web' },
...over,
}) as PaasAppWithProject
const site = (over: Partial<Site> = {}): Site =>
({
id: 's1',
org: 'acme',
slug: 'docs',
name: 'Docs',
repo: {},
framework: 'static',
status: 'live',
liveUrl: 'https://docs.acme.hanzo.app',
createdAt: 50,
updatedAt: 100,
...over,
}) as Site
describe('rows', () => {
it('folds an app onto the board with its project and operator state', () => {
const r = rowOfApp(app({ phase: 'Running', health: 'healthy' }))
expect(r).toMatchObject({
kind: 'app',
slug: 'api',
name: 'API',
project: 'web',
host: 'api.acme.hanzo.app',
status: 'live',
phase: 'Running',
health: 'healthy',
updatedAt: 200,
})
})
it('folds a site onto the same shape, host taken from the live URL', () => {
const r = rowOfSite(site())
expect(r).toMatchObject({ kind: 'site', slug: 'docs', host: 'docs.acme.hanzo.app', status: 'live' })
// A site has no project scope and no operator CR — absent, not faked.
expect(r.project).toBeUndefined()
expect(r.phase).toBeUndefined()
})
it('leaves host undefined when nothing is bound, rather than inventing one', () => {
expect(rowOfApp(app({ domains: [] })).host).toBeUndefined()
expect(rowOfApp(app({ domains: undefined })).host).toBeUndefined()
expect(rowOfSite(site({ liveUrl: undefined })).host).toBeUndefined()
expect(rowOfApp(app({ domains: [] })).hosts).toEqual([])
expect(rowOfSite(site({ liveUrl: undefined })).hosts).toEqual([])
})
it('carries EVERY bound host, not just the primary', () => {
// An app is born with its *.hanzo.app host, so a custom domain is the SECOND
// entry — folding to the primary would hide the domain someone bound.
const r = rowOfApp(app({ domains: ['api.acme.hanzo.app', 'api.example.com'] }))
expect(r.host).toBe('api.acme.hanzo.app')
expect(r.hosts).toEqual(['api.acme.hanzo.app', 'api.example.com'])
})
it('drops blank domain entries from the host list', () => {
expect(boundHosts(['', ' a.example.com ', ' '])).toEqual(['a.example.com'])
expect(boundHosts(undefined)).toEqual([])
})
it('skips blank domain entries when picking the primary host', () => {
expect(primaryHost(['', ' ', 'real.example.com'])).toBe('real.example.com')
expect(primaryHost([])).toBeUndefined()
})
it('falls back to the raw value when a live URL is not parseable', () => {
expect(hostOf('docs.example.com')).toBe('docs.example.com')
expect(hostOf('https://x.example.com/path')).toBe('x.example.com')
})
it('defaults a missing status to draft and a missing timestamp to 0', () => {
const r = rowOfApp(app({ status: undefined, updatedAt: undefined, createdAt: undefined }))
expect(r.status).toBe('draft')
expect(r.updatedAt).toBe(0)
})
it('uses createdAt when the row was never updated', () => {
expect(rowOfApp(app({ updatedAt: undefined, createdAt: 42 })).updatedAt).toBe(42)
})
})
describe('board', () => {
it('orders newest first without mutating the input', () => {
const rows = [rowOfSite(site()), rowOfApp(app())]
const sorted = byRecency(rows)
expect(sorted.map((r) => r.slug)).toEqual(['api', 'docs'])
expect(rows.map((r) => r.slug)).toEqual(['docs', 'api'])
})
it('counts only what the backend calls live', () => {
const rows = [rowOfApp(app()), rowOfApp(app({ id: 'a2', slug: 'w', status: 'building' })), rowOfSite(site())]
expect(summarize(rows)).toEqual({ total: 3, live: 2, apps: 2, sites: 1 })
})
})
describe('repoName', () => {
it('takes the last segment and drops the .git suffix', () => {
expect(repoName('https://git.hanzo.ai/hanzoai/console.git')).toBe('console')
expect(repoName('git@github.com:hanzoai/cloud.git')).toBe('cloud')
expect(repoName('https://git.hanzo.ai/hanzoai/console/')).toBe('console')
})
it('slugifies a segment that is not already a slug', () => {
expect(repoName('https://example.com/org/My App')).toBe('my-app')
})
it('is empty for a URL with no usable segment, so the form asks', () => {
expect(repoName('')).toBe('')
expect(repoName('///')).toBe('')
})
})
describe('parseEnv', () => {
it('splits on the FIRST = so a value may contain one', () => {
expect(parseEnv('URL=postgres://u:p@h/db?x=1')).toEqual([
{ key: 'URL', value: 'postgres://u:p@h/db?x=1', secret: true },
])
})
it('skips blanks, comments, and lines with no assignment', () => {
expect(parseEnv('\n# a comment\nPORT=8080\ngarbage\n \n')).toEqual([
{ key: 'PORT', value: '8080', secret: true },
])
})
it('drops a line that starts with = rather than storing an empty key', () => {
expect(parseEnv('=novalue')).toEqual([])
})
it('keeps an empty value when the key is real', () => {
expect(parseEnv('EMPTY=')).toEqual([{ key: 'EMPTY', value: '', secret: true }])
})
it('does not unquote or expand — a credential is stored verbatim', () => {
expect(parseEnv('TOKEN="ab$HOME"')).toEqual([{ key: 'TOKEN', value: '"ab$HOME"', secret: true }])
})
// The defect this replaced: a key-NAME regex decided secrecy, so these three
// real credential names sailed through as public while the form's help text
// promised they were sealed.
it.each(['STRIPE_SK', 'GH_PAT', 'DB_PASS', 'PORT', 'NODE_ENV'])('seals %s by default', (key) => {
expect(parseEnv(`${key}=x`)[0].secret).toBe(true)
})
it('opens ONLY the keys named public', () => {
const got = parseEnv('PORT=8080\nSTRIPE_SK=sk_live_x', new Set(['PORT']))
expect(got.map((e) => [e.key, e.secret])).toEqual([
['PORT', false],
['STRIPE_SK', true],
])
})
it('ignores a public key that is not present, and never opens by prefix', () => {
expect(parseEnv('DB_PASSWORD=x', new Set(['DB_PASS', 'NOPE']))[0].secret).toBe(true)
})
})
describe('envVars', () => {
it('reads the public list off the form', () => {
const form: DeployForm = { kind: 'app', name: 'a', repo: 'r', env: 'A=1\nB=2', publicKeys: ['A'] }
expect(envVars(form).map((e) => [e.key, e.secret])).toEqual([
['A', false],
['B', true],
])
})
it('seals everything when the form names nothing public', () => {
expect(envVars({ kind: 'app', name: 'a', repo: 'r', env: 'A=1' })[0].secret).toBe(true)
})
})
describe('prunePublicKeys', () => {
it('keeps a mark whose variable is still there', () => {
expect(prunePublicKeys('PORT=8080\nDEBUG=1', ['PORT'])).toEqual(['PORT'])
})
it('drops a mark whose variable was deleted', () => {
expect(prunePublicKeys('DEBUG=1', ['PORT'])).toEqual([])
expect(prunePublicKeys('', ['PORT'])).toEqual([])
})
// The attack this closes: mark a harmless DATABASE_URL Public, delete it, then
// type a new one carrying a password. Without pruning the stale mark is
// inherited and the credential ships unsealed.
it('does not let a mark be inherited by a later variable reusing the name', () => {
const marked = ['DATABASE_URL']
const afterDelete = prunePublicKeys('PORT=8080', marked)
expect(afterDelete).toEqual([])
const retyped = 'PORT=8080\nDATABASE_URL=postgres://u:secret@h/db'
expect(parseEnv(retyped, new Set(afterDelete)).find((e) => e.key === 'DATABASE_URL')?.secret).toBe(true)
})
it('matches exactly, so a case twin never inherits the mark', () => {
expect(prunePublicKeys('db_url=x', ['DB_URL'])).toEqual([])
expect(parseEnv('db_url=x', new Set(['DB_URL']))[0].secret).toBe(true)
})
it('ignores a commented-out line — a mark cannot survive on a comment', () => {
expect(prunePublicKeys('# PORT=8080', ['PORT'])).toEqual([])
})
})
describe('partialNote', () => {
it('names WHICH source is incomplete, so a reader knows which number lies', () => {
expect(partialNote(['app'])).toMatch(/^Apps could not/)
expect(partialNote(['site'])).toMatch(/^Sites could not/)
expect(partialNote(['app', 'site'])).toMatch(/^Apps and sites could not/)
})
it('is silent for a whole board', () => {
expect(partialNote([])).toBeNull()
})
})
describe('envError', () => {
it('accepts the keys the platform accepts', () => {
expect(envError('PORT=8080\n_PRIVATE=x\nA1=y')).toBeNull()
expect(envError('')).toBeNull()
})
it('names the first key the backend would reject, instead of letting it 400', () => {
expect(envError('PORT=8080\nMY-KEY=x')).toMatch(/"MY-KEY"/)
expect(envError('1BAD=x')).toMatch(/"1BAD"/)
})
})
describe('hostError', () => {
it('accepts a plain hostname', () => {
expect(hostError('app.example.com')).toBeNull()
expect(hostError('a-b.c.example.co.uk')).toBeNull()
})
it('is silent while the field is empty', () => {
expect(hostError('')).toBeNull()
expect(hostError(' ')).toBeNull()
})
it('rejects a URL, a path, and a port with a specific message', () => {
expect(hostError('https://app.example.com')).toMatch(/https:\/\//)
expect(hostError('app.example.com/x')).toMatch(/no path/)
expect(hostError('app.example.com:8080')).toMatch(/no port/)
})
it('rejects malformed hostnames', () => {
expect(hostError('example')).not.toBeNull()
expect(hostError('-bad.example.com')).not.toBeNull()
expect(hostError('app..example.com')).not.toBeNull()
expect(hostError('app example.com')).not.toBeNull()
})
it('rejects a hostname over the 253-character limit', () => {
expect(hostError(`${'a'.repeat(60)}.${'b'.repeat(60)}.${'c'.repeat(60)}.${'d'.repeat(60)}.example.com`))
.not.toBeNull()
})
})
describe('form → create input', () => {
const base: DeployForm = { kind: 'app', name: 'api', repo: 'https://git.hanzo.ai/hanzoai/api.git' }
it('maps a repo + host + env to the app create body, sealing env by default', () => {
expect(toAppInput({ ...base, branch: 'main', host: 'API.Example.COM ', env: 'PORT=8080' })).toEqual({
name: 'api',
source: 'git',
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git', branch: 'main' },
env: [{ key: 'PORT', value: '8080', secret: true }],
domains: ['api.example.com'],
})
})
it('carries the per-variable public choice into the create body', () => {
const out = toAppInput({ ...base, env: 'PORT=8080\nSTRIPE_SK=sk_live', publicKeys: ['PORT'] })
expect(out.env).toEqual([
{ key: 'PORT', value: '8080', secret: false },
{ key: 'STRIPE_SK', value: 'sk_live', secret: true },
])
})
it('omits branch, env, and domains rather than sending empty ones', () => {
expect(toAppInput(base)).toEqual({
name: 'api',
source: 'git',
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git' },
})
expect(toAppInput({ ...base, branch: ' ', host: ' ', env: '\n#c\n' })).toEqual({
name: 'api',
source: 'git',
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git' },
})
})
it('builds a site body with its framework, and omits an absent repo', () => {
expect(toSiteInput({ kind: 'site', name: 'docs', repo: '', framework: 'next' })).toEqual({
name: 'docs',
framework: 'next',
})
expect(toSiteInput({ kind: 'site', name: 'docs', repo: 'https://x/y.git', branch: 'main' })).toEqual({
name: 'docs',
framework: 'static',
repo: { url: 'https://x/y.git', branch: 'main' },
})
})
})
describe('formError', () => {
const app_: DeployForm = { kind: 'app', name: 'api', repo: 'https://x/y.git' }
it('passes a complete app form', () => {
expect(formError(app_, 'web')).toBeNull()
})
it('demands a name, a project, and a repo for an app', () => {
expect(formError({ ...app_, name: ' ' }, 'web')).toMatch(/Name/)
expect(formError(app_, null)).toMatch(/project/)
expect(formError({ ...app_, repo: '' }, 'web')).toMatch(/Repository/)
})
it('surfaces a bad host', () => {
expect(formError({ ...app_, host: 'https://x.com' }, 'web')).toMatch(/https:\/\//)
})
it('surfaces a bad env key before the request is made', () => {
expect(formError({ ...app_, env: 'MY-KEY=x' }, 'web')).toMatch(/"MY-KEY"/)
})
it('needs neither project nor repo for a site', () => {
expect(formError({ kind: 'site', name: 'docs', repo: '' }, null)).toBeNull()
})
})
describe('hostRows', () => {
it('expands a row into ONE row per bound host, so a custom domain shows', () => {
const rows = [rowOfApp(app({ domains: ['api.acme.hanzo.app', 'api.example.com'] })), rowOfSite(site())]
expect(hostRows(rows).map((h) => h.host)).toEqual([
'api.acme.hanzo.app',
'api.example.com',
'docs.acme.hanzo.app',
])
})
it('attributes every host to its owner and kind', () => {
expect(hostRows([rowOfSite(site())])).toEqual([
{ host: 'docs.acme.hanzo.app', owner: 'Docs', kind: 'site', status: 'live' },
])
})
it('omits a row with nothing bound', () => {
expect(hostRows([rowOfApp(app({ domains: [] }))])).toEqual([])
})
})
-292
View File
@@ -1,292 +0,0 @@
/**
* The deploy board one row shape over the two things an org deploys.
*
* An APP is a container workload the operator reconciles (`/v1/platform/projects/
* :project/apps`, `PaasApi`); a SITE is a static build served from object storage
* (`/v1/platform/sites`, `PlatformSitesApi`). They are separate backends on
* purpose, but a person reading "what have I shipped" wants ONE list, so this
* module folds both into `DeployRow` and nothing downstream branches on which
* store a row came from.
*
* Pure by construction: no React, no fetch, no clock. The panels do the I/O with
* the existing typed clients and hand the results here, which is what makes the
* mapping (form create input, env text env vars, app row) testable without
* a browser or a server.
*
* ORG SCOPING IS NOT DONE HERE, and that is deliberate. Every read/write goes
* through the same-origin `/v1` bearer proxy, which resolves the org from the
* token owner server-side. This module never sees, sends, or filters by an org
* a client-side filter would read like a boundary while being none.
*/
import { slugify } from '~/lib/framework/fields'
import type { PaasAppWithProject, PaasEnvVar, CreateAppInput } from '~/lib/api/paas'
import type { Site, CreateSiteInput } from '~/lib/api/platform-sites'
/** What a row deploys: a reconciled container workload, or a static build. */
export type DeployKind = 'app' | 'site'
/** One shipped thing, whichever store holds it. */
export type DeployRow = {
kind: DeployKind
id: string
/** Backend key: an app's slug (unique within its project) or a site's slug. */
slug: string
name: string
/** Owning project — apps only; a site is scoped to the org directly. */
project?: string
/** Primary public host, absent until one is bound — the board's Host column. */
host?: string
/**
* EVERY host bound to this row, primary first. Kept alongside `host` because a
* custom domain is usually the second entry (an app is born with its
* `*.hanzo.app` host), so a Domains view built from `host` alone would hide
* exactly the domain someone went to the trouble of binding.
*/
hosts: string[]
/** Backend lifecycle: draft | building | deploying | live | stopped | error. */
status: string
/** Operator reconciliation, read straight off the App CR. Apps only. */
phase?: string
health?: string
updatedAt: number
}
/** Every non-blank bound host, order preserved. */
export const boundHosts = (domains?: string[]): string[] =>
(domains ?? []).map((d) => d.trim()).filter((d) => d.length > 0)
/** The first bound host, or undefined — never a fabricated default. */
export const primaryHost = (domains?: string[]): string | undefined => boundHosts(domains)[0]
export function rowOfApp(app: PaasAppWithProject): DeployRow {
const hosts = boundHosts(app.domains)
return {
kind: 'app',
id: app.id,
slug: app.slug,
name: app.name || app.slug,
project: app.project?.slug,
host: hosts[0],
hosts,
status: app.status || 'draft',
phase: app.phase,
health: app.health,
updatedAt: app.updatedAt ?? app.createdAt ?? 0,
}
}
export function rowOfSite(site: Site): DeployRow {
// A site reports ONE public host (its live URL); custom hosts are bound through
// the site's own domains endpoint, which this board does not read per row.
const host = site.liveUrl ? hostOf(site.liveUrl) : undefined
return {
kind: 'site',
id: site.id,
slug: site.slug,
name: site.name || site.slug,
host,
hosts: host ? [host] : [],
status: site.status || 'draft',
updatedAt: site.updatedAt ?? site.createdAt ?? 0,
}
}
/** Hostname out of a URL; the input unchanged when it isn't one. */
export function hostOf(url: string): string {
try {
return new URL(url).host
} catch {
return url
}
}
/** Newest first — the order a deploy board is read in. */
export const byRecency = (rows: DeployRow[]): DeployRow[] =>
[...rows].sort((a, b) => b.updatedAt - a.updatedAt)
/**
* The sentence for a board that loaded only part of itself, or null when it is
* whole. Named per source, because "some data is missing" leaves a reader unable
* to tell which number on the screen is now a lie.
*/
export function partialNote(incomplete: readonly DeployKind[]): string | null {
const apps = incomplete.includes('app')
const sites = incomplete.includes('site')
if (apps && sites) return 'Apps and sites could not be fully loaded — this list is incomplete.'
if (apps) return 'Apps could not be fully loaded — this list is incomplete.'
if (sites) return 'Sites could not be fully loaded — this list is incomplete.'
return null
}
/** Board headline counts. `live` is the backend's own word, never inferred. */
export function summarize(rows: DeployRow[]): { total: number; live: number; apps: number; sites: number } {
return {
total: rows.length,
live: rows.filter((r) => r.status === 'live').length,
apps: rows.filter((r) => r.kind === 'app').length,
sites: rows.filter((r) => r.kind === 'site').length,
}
}
/** One published host, and the app or site it belongs to. */
export type HostRow = { host: string; owner: string; kind: DeployKind; status: string }
/**
* Every host on the board, one row each EXPANDED over `hosts`, not folded to
* the primary. An app is born with its `*.hanzo.app` host, so a custom domain is
* the second entry; listing only the primary would hide exactly the domain
* someone bound on purpose.
*/
export function hostRows(rows: DeployRow[]): HostRow[] {
return rows
.flatMap((r) => r.hosts.map((host) => ({ host, owner: r.name, kind: r.kind, status: r.status })))
.sort((a, b) => a.host.localeCompare(b.host))
}
// ── Deploy form → backend create input ───────────────────────────────────────
/**
* Default app/site name from a repo URL: the last path segment, `.git` dropped.
* `https://git.hanzo.ai/hanzoai/console.git` `console`. Empty when the URL has
* no usable segment, so the form asks rather than inventing a name.
*/
export function repoName(url: string): string {
const last = url.trim().replace(/\/+$/, '').split('/').pop() ?? ''
return slugify(last.replace(/\.git$/i, ''))
}
/**
* `KEY=VALUE` lines env vars. Blank lines and `#` comments are skipped, the
* first `=` splits (so a value may contain `=`), and a line without one is
* dropped rather than stored as a key with an empty value.
*
* EVERY variable is secret unless its key appears in `publicKeys` an explicit
* choice the person deploying makes per variable. This replaced a key-NAME regex
* (`/SECRET|TOKEN|KEY|…/`) that guessed, and guessed wrong in both directions:
* `STRIPE_SK`, `GH_PAT` and `DB_PASS` all name credentials and all escaped it, while
* the form's own help text promised they would be sealed. A default that fails
* open under a promise of safety is worse than no default, so the default is now
* sealed and the exceptions are named.
*
* Values are passed through verbatim no trimming beyond surrounding
* whitespace, no unquoting, no expansion. Interpreting `$VAR` or stripping
* quotes here would silently change a credential.
*/
export function parseEnv(text: string, publicKeys: ReadonlySet<string> = new Set()): PaasEnvVar[] {
const out: PaasEnvVar[] = []
for (const raw of text.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq <= 0) continue
const key = line.slice(0, eq).trim()
if (!key) continue
out.push({ key, value: line.slice(eq + 1).trim(), secret: !publicKeys.has(key) })
}
return out
}
/**
* The env-key rule the platform enforces (`^[A-Za-z_][A-Za-z0-9_]*$`). Checked here
* so a stray `MY-KEY` is named in the form instead of coming back as a bare 400.
*/
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/
/** Message naming the first key the backend would reject, else null. */
export function envError(text: string): string | null {
const bad = parseEnv(text).find((e) => !ENV_KEY.test(e.key))
return bad ? `"${bad.key}" is not a valid env name — letters, digits, and _ only, not starting with a digit.` : null
}
/** A hostname is 1253 chars of dot-separated LDH labels; no scheme, port, or path. */
const HOST = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
/**
* Message when `host` is not a bindable hostname, else null.
*
* UX validation only. The server is the authority on whether this org may bind
* this host (it demands ownership verification and 409s a host another org
* holds); this only stops an obviously malformed value from becoming a request.
*/
export function hostError(host: string): string | null {
const v = host.trim()
if (!v) return null
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return 'Hostname only — drop the https:// prefix.'
if (v.includes('/')) return 'Hostname only — no path.'
if (v.includes(':')) return 'Hostname only — no port.'
if (!HOST.test(v.toLowerCase())) return 'Not a valid hostname (e.g. app.example.com).'
return null
}
/** What the deploy form collects, before it is shaped for either backend. */
export type DeployForm = {
kind: DeployKind
name: string
/** Git URL to build from; a site always builds from git. */
repo: string
branch?: string
/** Custom host to bind. Optional — every app is born with a default host. */
host?: string
/** Raw `KEY=VALUE` lines from the textarea. Apps only. */
env?: string
/**
* Keys the person explicitly marked PUBLIC. Everything else is sealed the
* list names the exceptions, so forgetting to touch a variable leaves it safe.
*/
publicKeys?: string[]
framework?: string
}
/** The variables a form will send, each carrying its sealed/public choice. */
export const envVars = (form: DeployForm): PaasEnvVar[] =>
parseEnv(form.env ?? '', new Set(form.publicKeys ?? []))
/**
* The public marks that still have a variable, given the current env text.
*
* A mark must not outlive the line it was made on. Without this, deleting
* `DATABASE_URL=postgres://safe` (marked Public) and later typing a new
* `DATABASE_URL=` carrying a password would silently inherit the old mark and
* ship the credential unsealed the mark would be a property of a NAME rather
* than of the variable someone actually looked at.
*
* Matching is exact, so `db_url` never inherits `DB_URL`'s mark: a case twin is a
* different key to the backend, and failing closed on the ambiguity is correct.
*/
export function prunePublicKeys(env: string, publicKeys: readonly string[]): string[] {
const present = new Set(parseEnv(env).map((e) => e.key))
return publicKeys.filter((k) => present.has(k))
}
/** The app create body (`POST /v1/platform/projects/:project/apps`). */
export function toAppInput(form: DeployForm): CreateAppInput {
const host = form.host?.trim().toLowerCase()
const env = envVars(form)
return {
name: form.name.trim(),
source: 'git',
repo: { url: form.repo.trim(), ...(form.branch?.trim() ? { branch: form.branch.trim() } : {}) },
...(env.length ? { env } : {}),
...(host ? { domains: [host] } : {}),
}
}
/** The site create body (`POST /v1/platform/sites`). */
export function toSiteInput(form: DeployForm): CreateSiteInput {
const repo = form.repo.trim()
return {
name: form.name.trim(),
framework: form.framework || 'static',
...(repo ? { repo: { url: repo, ...(form.branch?.trim() ? { branch: form.branch.trim() } : {}) } } : {}),
}
}
/** Message when the form cannot be submitted yet, else null. */
export function formError(form: DeployForm, project: string | null): string | null {
if (!form.name.trim()) return 'Name is required.'
if (form.kind === 'app' && !project) return 'Pick a project.'
if (form.kind === 'app' && !form.repo.trim()) return 'Repository URL is required.'
const env = form.env ? envError(form.env) : null
if (env) return env
return form.host ? hostError(form.host) : null
}
-6
View File
@@ -42,12 +42,6 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
'profile', // the signed-in user's own profile
'api-keys', // credentials to call the API
'platform', // the project HUB — create/deploy/ship a project (first-class, every org)
// The deploy FRONT DOOR (apps, sites, domains, and the CD/CI/storage readings).
// Always-on for the same reason 'platform' is: shipping your own code is not a
// separate SKU, and it grants no backend reach — every head it reads was already
// reachable, and cloud still enforces authz and the spend gate server-side. Gating
// it would hide the primary section of platform.<brand> behind "Add product".
'deploy',
'tracker', // native @hanzo/gui issue tracker — first-class work surface, every org (peer of the HUB; replaces the retired Huly/hanzo.team)
'guide', // the Business AI launch checklist — foundational onboarding, every org (like 'platform')
'company', // self-service incorporation — the formation wizard, foundational for every org (peer of 'platform')
+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_PUBLISHABLE_KEY
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
// here.
//
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
+2
View File
@@ -322,6 +322,7 @@ describe('canonicalSlug — conventional URLs map to the canonical entry id', ()
it('maps the human product slugs the console/e2e/bookmarks use to the canonical id', () => {
// These six were the biggest "blank" source: a human slug ≠ registry id → 404.
expect(canonicalSlug(['traces'])).toEqual(['o11y'])
expect(canonicalSlug(['deploy'])).toEqual(['app-platform'])
expect(canonicalSlug(['plans-pricing'])).toEqual(['plans'])
expect(canonicalSlug(['wallets'])).toEqual(['wallet'])
expect(canonicalSlug(['model-catalog'])).toEqual(['models'])
@@ -360,6 +361,7 @@ describe('resolveProductView — aliases + external resolve (never a 404 nav ite
it('every human product slug resolves to its real module (never a 404 blank)', () => {
const cases: [string, string][] = [
['traces', 'o11y'],
['deploy', 'app-platform'],
['plans-pricing', 'plans'],
['wallets', 'wallet'],
['model-catalog', 'models'],
+1 -5
View File
@@ -82,11 +82,7 @@ export const SLUG_ALIASES: Record<string, string> = {
auto: 'automations',
automation: 'automations',
traces: 'o11y',
// `deploy` used to alias App Platform, back when the PaaS canvas was the only
// place a deploy happened. It is a real product now — the front door over apps,
// sites, domains, CD, CI, and storage — so the slug resolves to itself and App
// Platform keeps its own id. An alias to a sibling would make the front door
// unreachable: `canonicalSlug` rewrites the head before any lookup.
deploy: 'app-platform',
'plans-pricing': 'plans',
wallets: 'wallet',
'model-catalog': 'models',
+1 -47
View File
@@ -1,14 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
categoryIsOpen,
toggleCategory,
productIsOpen,
toggleProduct,
NAV_OPEN_PREF,
NAV_PRODUCT_OPEN_PREF,
type CategoryOpen,
} from './nav-accordion'
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav-accordion'
const ctx = (filtering = false) => ({ filtering })
@@ -76,41 +68,3 @@ describe('toggleCategory (independent per-section)', () => {
expect(next).not.toBe(stored)
})
})
describe('productIsOpen / toggleProduct', () => {
// Where you are is the one product whose options you are certain to want.
it('opens the ACTIVE product and leaves the others closed', () => {
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
})
it('respects an explicit choice over the active default, in both directions', () => {
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
})
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
// matches, so expanding them would push the other hits off screen.
it('closes everything while filtering, active or not', () => {
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
})
// Whichever way the chevron points, the click does that.
it('first click on the active one collapses it; on any other one expands it', () => {
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
})
it('leaves every other product untouched and never mutates the input', () => {
const stored = { models: true }
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
expect(stored).toEqual({ models: true })
})
// Products and categories share a preference SHAPE but not a default, and they are
// stored under different keys — a product must never inherit a category's open-by-default.
it('is keyed apart from the category accordion', () => {
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
})
})
-46
View File
@@ -57,49 +57,3 @@ export function toggleCategory(stored: CategoryOpen, category: string): Category
const current = stored[category] === undefined ? true : stored[category]
return { ...stored, [category]: !current }
}
// ── Products, which expand IN PLACE ─────────────────────────────────────────
//
// A product with sub-pages expands beneath its own row, so its options appear
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
// all products" button. The options were the same either way; what the drill took
// away was every other product, which is exactly what a person needs to see when the
// reason they clicked was to compare or to move on somewhere else.
//
// The default here is the OPPOSITE of a category's, and deliberately: categories are
// few and describe the whole catalog, so they open; products are many and each brings
// four to eight rows, so opening them all would bury the catalog under its own detail.
/** Preference key for which products are expanded in the rail. */
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
/**
* Whether a product's sub-pages render EXPANDED:
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
* sub-pages are not themselves matches expanding them would push the other hits
* off screen;
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
* is the one place whose options you are certain to want;
* - otherwise: the user's explicit choice, else CLOSED.
*/
export function productIsOpen(
stored: CategoryOpen,
id: string,
ctx: { filtering: boolean; active: boolean },
): boolean {
if (ctx.filtering) return false
const v = stored[id]
return v === undefined ? ctx.active : v
}
/**
* Toggle one product's expansion (pure + immutable). The stored value is what the
* product is being toggled AWAY from, so the first click on the active product
* collapses it and the first click on any other one expands it in both cases the
* click does the thing the chevron was pointing at.
*/
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
const current = stored[id] === undefined ? ctx.active : stored[id]
return { ...stored, [id]: !current }
}
+2 -48
View File
@@ -305,7 +305,6 @@ import { ProjectsModule as FleetProjectsModule } from '~/components/products/adm
import { BetaFeaturesModule } from '~/components/products/BetaFeaturesModule'
// GitOps — the native ArgoCD replacement (SuperAdmin operator surface).
import { GitOpsModule } from '~/components/products/gitops/GitOpsModule'
import { DeployModule } from '~/components/products/deploy/DeployModule'
import { ContactModule } from '~/components/products/ContactModule'
/** A Hanzo GUI icon component (e.g. `Server` from `@hanzogui/lucide-icons-2`). */
@@ -1237,10 +1236,6 @@ export const catalog: CatalogEntry[] = [
{ path: ':tab', component: AgentsModule },
],
subpages: [
// The guided way in: describe an agent or start from a template, configure it in
// the ONE builder, run it, and take the call away. Leads the sub-nav because it
// is where someone with no agents yet should land.
{ slug: 'quickstart', label: 'Quickstart' },
{ slug: 'status', label: 'Status' },
{ slug: 'logs', label: 'Logs' },
{ slug: 'metrics', label: 'Metrics' },
@@ -2112,44 +2107,7 @@ export const catalog: CatalogEntry[] = [
],
},
{
// Deploy — the front door for shipping. ONE section over the two things an org
// deploys (container apps via `/v1/platform/projects/:p/apps`, static sites via
// `/v1/platform/sites`) plus readings of the three planes a deploy touches:
// CD (`/v1/deploy/applications` — reconciliation of the caller's own App CRs),
// CI (`/v1/builds`), and Storage (`/v1/s3/buckets`). Each is the ONE canonical
// head for its subject; there is deliberately no `/v1/platform/cd|ci|s3` alias,
// which would give the estate two paths to the same data.
//
// It COMPOSES the existing typed clients rather than re-implementing them, and
// deep-links to the product that owns each subject for anything deeper — so App
// Platform stays the place to operate one app, and S3 to browse objects.
// NOT admin-gated: shipping your own code is the customer's own business, and
// every read is org-scoped server-side from the bearer proxy's token owner.
id: 'deploy',
label: 'Deploy',
icon: Rocket,
description:
'Ship an app or a static site — pick a repo, bind a host, set env, deploy. Then watch CD reconcile it, CI build it, and storage serve it.',
gcp: 'Cloud Deploy',
category: 'Platform',
status: 'enabled',
repo: 'hanzoai/cloud',
kind: 'module',
routes: [
{ path: '', component: DeployModule },
{ path: ':tab', component: DeployModule },
],
subpages: [
{ slug: 'apps', label: 'Apps', icon: AppWindow },
{ slug: 'sites', label: 'Sites', icon: Globe },
{ slug: 'domains', label: 'Domains', icon: Cable },
{ slug: 'cd', label: 'CD', icon: GitBranch },
{ slug: 'ci', label: 'CI', icon: Hammer },
{ slug: 'storage', label: 'Storage', icon: HardDrive },
],
},
{
// The native ArgoCD replacement, rendered as a Railway-grade fleet MAP
// Deploy — the native ArgoCD replacement, rendered as a Railway-grade fleet MAP
// (the surface cd.hanzo.ai serves). A PLATFORM surface (admin: true → hidden from
// every customer's nav/palette today; the org-scoped projection opens it
// per-org) that reads the live operator App CRs through cloud's /v1/deploy/* — the
@@ -2158,12 +2116,8 @@ export const catalog: CatalogEntry[] = [
// sync, owned-resource topology, CI builds, logs, and confirm-gated Sync/Rollback
// (rollback pins the CR image tag to a prior clean-semver release → the operator
// reconciles). The map/drawer are the shared @hanzo/canvas primitive.
// Labelled `Fleet`, not `Deploy`: this is the ESTATE map (every org's App CRs,
// admin-only), while `deploy` above is the customer's own front door. Two
// entries labelled "Deploy" would have sat side by side in a SuperAdmin's
// Platform section, one of them showing somebody else's workloads.
id: 'gitops',
label: 'Fleet',
label: 'Deploy',
icon: GitBranch,
description: 'The fleet deploy map — every operator App CR as a live node with reconciled health, sync, resource topology, CI builds, logs, and one-click rollback. The Hanzo operator reconciles.',
gcp: 'Cloud Deploy',
-13
View File
@@ -80,19 +80,6 @@ describe('allowCloudSurface', () => {
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
})
// The tool plane is admitted so the agent builder can offer an org's REAL tool
// names. Discovery only: a head admits every sub-path, and `POST /v1/tools/call`
// RUNS a tool — that belongs to whatever runs an agent, never to a browser tab.
it('admits tool discovery but refuses the dispatch door', () => {
expect(CLOUD_HEADS).toContain('tools')
expect(allowCloudSurface('v1/tools')).toBe(true)
expect(allowCloudSurface('v1/tools?activated=true')).toBe(true)
expect(allowCloudSurface('v1/tools/catalog')).toBe(true)
expect(allowCloudSurface('v1/tools/call')).toBe(false)
expect(allowCloudSurface('/v1/tools/call')).toBe(false)
expect(allowCloudSurface('v1/tools/call?x=1')).toBe(false)
})
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
expect(CLOUD_HEADS).toContain('evals')
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
+1 -16
View File
@@ -33,13 +33,6 @@ export const CLOUD_HEADS: readonly string[] = [
'functions',
'prompts',
'agents',
// The unified tool plane (cloud apps/tools): /v1/tools — discovery across every
// source (connector actions, functions, zap-service routes, agents, skills, the
// org's own MCP servers), deduplicated by name. `scopeOf` derives the org+project
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1
// exactly like agents/prompts. Discovery only — `/v1/tools/call` is refused below,
// because running a tool belongs to whatever runs an agent, not to a browser tab.
'tools',
// Login manager (cloud clients/link): /v1/links[/…] — the org+user-scoped registry
// of which AI provider accounts are signed in on which machines + their usage. The
// handler resolves org from the Bearer owner + the user from the validated subject
@@ -402,15 +395,7 @@ export function v1Head(path: string): string | null {
* rule would break a live surface while claiming to preserve a property that never
* covered it. Defense in depth the backend gates cross-tenant reads on its own.
*/
const REFUSED_SUBPATHS: readonly RegExp[] = [
/^v1\/ai\/stores\/global(?:$|[/?#])/,
// The tool plane's DISPATCH door. `tools` is allow-listed for discovery — the agent
// builder needs to offer the org's real tool names — but a head admits every
// sub-path, and `POST /v1/tools/call` RUNS a tool. Executing one belongs to whatever
// runs an agent, never to a form in a browser tab, so the console's proxy is a
// read-only window onto the plane.
/^v1\/tools\/call(?:$|[/?#])/,
]
const REFUSED_SUBPATHS: readonly RegExp[] = [/^v1\/ai\/stores\/global(?:$|[/?#])/]
export function allowCloudSurface(path: string): boolean {
const rel = path.replace(/^\/+/, '')