tabs: a cloud machine is a sandbox

New cloud machine ordered a DigitalOcean droplet through visor, waited a
minute for it to boot and run `hanzo link`, and — with no name in the body —
answered 422 "Droplet must have a name", so in production it ordered nothing
at all.

It leases a dev sandbox now. POST /v1/sandboxes returns when the pod is
RUNNING, so the machine and the news of it arrive together, and the button
opens its first shell rather than adding a row to a list. The project counts
up against what is already live, because the server keeps one live sandbox
per project — which is what makes a second press a second machine instead of
a second attempt at the first.

That leaves one plane where there were two: visor's launch, list, and the
merge that held a place for a box still booting all go, along with the
pending statuses that existed only for that gap. A failed start now carries
the server's own reason to the button that caused it.
This commit is contained in:
zooqueen
2026-08-10 21:45:14 -07:00
parent 83789c921b
commit 4a09925faa
6 changed files with 158 additions and 196 deletions
+26 -27
View File
@@ -18,12 +18,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
type CloudMachine,
type Machine,
type SandboxMachine,
type Session,
cloudMachines,
launchCloudMachine,
createSandbox,
machineName,
machines,
sandboxes,
sandboxTerminal,
@@ -31,7 +30,6 @@ import {
terminalTicket,
} from '@/lib/api';
import { renew, session, signIn, signOut } from '@/lib/iam';
import { withCloud } from '@/lib/panes';
import { Workspace, type TerminalHost } from '@/components/workspace';
export default function App() {
@@ -40,7 +38,6 @@ export default function App() {
const [data, setData] = useState<{
m: Machine[];
s: Session[];
c: CloudMachine[];
b: SandboxMachine[];
} | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -60,21 +57,18 @@ export default function App() {
const refresh = useCallback(async (t: string) => {
try {
// Two planes, and only one of them holds this page up. The registry is where
// machines and terminals come from; visor only knows about the boxes we
// launched, so provisioning being down has to cost you those and nothing
// else. It is caught here rather than reported here because the launch
// button is where a broken visor is worth saying out loud — a banner about
// a plane you may not be using is noise on a workspace that still works.
const [m, s, c, b] = await Promise.all([
// The registry is where machines and terminals come from, and it is what
// holds this page up. Sandboxes are a SECOND read against the same plane:
// caught here rather than reported, because their absence costs you those
// panes and nothing else, and a banner about boxes you may not have is
// noise on a workspace that still works. It is worth saying out loud at
// the launch button, which is the one place it stops you.
const [m, s, b] = await Promise.all([
machines(t),
sessions(t),
cloudMachines(t).catch(() => []),
// The builder's pods. A third plane with the same posture as visor:
// its absence costs you those panes and nothing else.
sandboxes(t).catch(() => []),
]);
setData({ m, s, c, b });
setData({ m, s, b });
setError(null);
} catch (e) {
// A registry that cannot be read is reported as such. Rendering an empty
@@ -121,19 +115,16 @@ export default function App() {
for (const [host, url] of base) {
if (!out.has(host)) out.set(host, { machine: host, base: url, status: 'online' });
}
// The builder's sandboxes. A sandbox has no tunnel to publish — its
// terminal URL is MINTED per open (single-use ticket), so the host carries
// the sandbox id instead of a base and the workspace mints when a pane
// binds. One live sandbox per project is the server's rule, so the project
// name is a stable, unique machine name.
// The sandboxes. A sandbox has no tunnel to publish — its terminal URL is
// MINTED per open (single-use ticket), so the host carries the sandbox id
// instead of a base and the workspace mints when a pane binds. One live
// sandbox per project is the server's rule, so the project name is a
// stable, unique machine name.
for (const s of data.b) {
const key = s.project || `box-${s.id.slice(0, 6)}`;
const key = machineName(s);
if (!out.has(key)) out.set(key, { machine: key, sandbox: s.id, status: 'online' });
}
// A box we launched exists to visor before it exists to the registry. It is
// merged in for that gap only, and stops being a special case the moment its
// own `hanzo link` registers it above.
return withCloud([...out.values()], data.c, (machine, status) => ({ machine, status }));
return [...out.values()];
}, [data]);
/** A fresh framed-terminal URL for a sandbox pane — ticket minted here, where
@@ -202,7 +193,15 @@ export default function App() {
<Workspace
hosts={hosts}
mint={mint}
onLaunch={() => launchCloudMachine(token).then(() => refresh(token))}
// Started, then read back, then named — in that order. The workspace
// opens a shell on the name it gets, and a pane can only mint a
// ticket for a machine the registry has already handed back, so the
// refresh is what stands between the two.
onLaunch={async () => {
const box = await createSandbox(token);
await refresh(token);
return machineName(box);
}}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-neutral-600">
+16 -10
View File
@@ -55,7 +55,6 @@ import {
DOT,
TERM_DEADLINE,
type Binding,
isPending,
isTermReady,
mintName,
rescued,
@@ -102,15 +101,14 @@ export function Workspace({
/** A fresh framed-terminal URL for a sandbox pane. The caller owns the
* credential; the workspace only ever holds the minted, ticket-bearing URL. */
mint?: (sandbox: string, shell: string) => Promise<string>;
onLaunch?: () => Promise<void>;
/** Start a cloud machine, and answer the name it goes by here. */
onLaunch?: () => Promise<string>;
}) {
// A box we launched is admitted before it has a tunnel. Without that it would be
// invisible for the minute it takes to boot and link, and a launch you cannot
// see is a button that looks broken. Everything else with no `base` is a machine
// serving no terminal, and there is nothing to open on one of those — except a
// sandbox, whose URL is minted on bind rather than published.
// A machine with no `base` serves no terminal, and there is nothing to open on
// one of those — except a sandbox, whose URL is minted on bind rather than
// published.
const live = useMemo(
() => hosts.filter((h) => (h.base || h.sandbox || isPending(h.status)) && h.status !== 'offline'),
() => hosts.filter((h) => (h.base || h.sandbox) && h.status !== 'offline'),
[hosts],
);
@@ -194,6 +192,14 @@ export function Workspace({
[takenOn],
);
/** Start a machine and open its first shell. The point of the button is the
* terminal: a machine that arrives with nothing framed on it is a row in a
* list, and you would have to go and ask for the shell you already asked for. */
const launch = useCallback(async () => {
if (!onLaunch) return;
open(await onLaunch(), focus ? 'row' : null, focus);
}, [onLaunch, open, focus]);
const doClose = useCallback((id: string) => {
setTile((t) => (t ? closePane(t, id) : null));
setBind((b) => {
@@ -371,7 +377,7 @@ export function Workspace({
</p>
{/* The header is not rendered in this branch, and someone with no machine
at all is exactly who has nowhere else to get one. */}
{onLaunch ? <Launch run={onLaunch} /> : null}
{onLaunch ? <Launch run={launch} /> : null}
</div>
);
}
@@ -393,7 +399,7 @@ export function Workspace({
>
<Plus className="h-3.5 w-3.5" /> New shell
</button>
{onLaunch ? <Launch run={onLaunch} /> : null}
{onLaunch ? <Launch run={launch} /> : null}
<span className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
+50 -67
View File
@@ -43,73 +43,7 @@ export const sessions = (token: string) =>
read<{ sessions: Session[] }>('/v1/agents/sessions?limit=200', token).then((r) => r.sessions ?? []);
/**
* Cloud machines — the boxes we launch for you, rather than the ones you linked.
*
* A different plane, and deliberately not hidden behind the first one: visor
* owns provisioning (launch, list, destroy, per-org billing over Hanzo's house
* account), api.hanzo.ai owns the live agent registry. Proxying one through the
* other would put Tabs in the middle of a conversation it is not part of, which
* is the same reason this app has no backend.
*
* They MEET at the machine. A launched box runs `hanzo link` exactly as your
* laptop does, so it registers itself and its terminal shows up in the registry
* above with no special case anywhere — a cloud machine is not a new kind of
* pane, it is a machine that happens to have been started by us.
*/
export const VISOR = process.env.NEXT_PUBLIC_HANZO_VISOR ?? 'https://visor.hanzo.ai';
/** What visor reports about a box. `state` is the provider's, `tag` carries kind. */
export interface CloudMachine {
name: string;
id: string;
displayName?: string;
provider?: string;
region?: string;
size?: string;
state?: string;
tag?: string;
}
async function visorCall<T>(path: string, token: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${VISOR}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.body ? { 'content-type': 'application/json' } : {}),
},
cache: 'no-store',
});
if (!res.ok) throw new Error(`${path} → ${res.status}`);
const body = (await res.json()) as { status?: string; msg?: string; data?: T };
// Visor answers 200 with {status:"error"} — the SDK contract is to branch on
// the field, not the code, so a failed launch that read as success would look
// like a machine that never appears.
if (body.status === 'error') throw new Error(body.msg || 'visor refused the request');
return (body.data ?? body) as T;
}
/** Boxes this org has running. `kind=tab` filters to the ones you can open. */
export const cloudMachines = (token: string, kind = 'tab') =>
visorCall<CloudMachine[]>(`/v1/machines?kind=${encodeURIComponent(kind)}`, token).then(
(m) => m ?? [],
);
/**
* Launch one.
*
* `kind: 'tab'` and not `'bot'`: a tab runs `hanzo link` and stops there, so you
* get a shell without bootstrapping an agent runtime nobody asked for — and the
* runtime is the expensive half. See visor service/analytics.go, where the kinds
* nest: machine ⊂ tab ⊂ bot.
*/
export const launchCloudMachine = (token: string, name?: string) =>
visorCall<CloudMachine>('/v1/machines/launch', token, {
method: 'POST',
body: JSON.stringify({ kind: 'tab', name: name || undefined }),
});
/**
* Dev sandboxes — the third kind of machine, and the ONE terminal contract.
* Sandboxes — the machines we start for you, rather than the ones you linked.
*
* The builder's pods (hanzo.app, console.hanzo.ai) serve the same framed
* terminal every other surface uses: cloud hosts the whole emulator page at
@@ -118,6 +52,12 @@ export const launchCloudMachine = (token: string, name?: string) =>
* kind — it is a machine whose terminal URL is MINTED per open instead of
* published by `hanzo link`, and `?arg=` names the tmux session exactly as a
* linked machine's ttyd does.
*
* It is the SAME plane as the registry above, which is why there is no second
* base URL here. A cloud machine used to be a DigitalOcean droplet ordered
* through visor: a second provisioning plane, a second failure mode, and a
* minute of boot and `hanzo link` before it could serve a shell. A sandbox is a
* pod, so the request that asks for one comes back when it is already running.
*/
export interface SandboxMachine {
id: string;
@@ -130,6 +70,49 @@ export const sandboxes = (token: string) =>
(r.sandboxes ?? []).filter((s) => s.status === 'running'),
);
/** How a sandbox names itself in the workspace. Its project is what its DISK is
* keyed on, so it is the name that means the same box tomorrow. */
export const machineName = (s: SandboxMachine) => s.project || `box-${s.id.slice(0, 6)}`;
/** The project a first cloud machine takes; the ones after it count up. */
const PROJECT = 'tabs';
/**
* Start one, and answer it once it is RUNNING.
*
* `class: 'dev'` is the machine you shell into — a toolchain, a home, and a disk
* that outlives the lease — as against `exec`, which is the code interpreter's
* scratch pod. A dev sandbox is named by its project, and the project is what
* the disk is keyed on: one live sandbox per project is the server's rule, so
* the name is picked against what is already live rather than made up. That is
* what makes a second press a second machine instead of a second attempt at the
* first one.
*
* The lease is the server's to set. Tabs has no opinion about how long a machine
* should live, and a `ttlSec` here would be a second answer to drift from the one
* in the class table.
*/
export const createSandbox = async (token: string): Promise<SandboxMachine> => {
const taken = new Set((await sandboxes(token)).map(machineName));
let project = PROJECT;
for (let n = 2; taken.has(project); n++) project = `${PROJECT}-${n}`;
const res = await fetch(`${API}/v1/sandboxes`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ class: 'dev', project }),
cache: 'no-store',
});
// Starting a machine fails for reasons a person can act on — an image that
// will not pull, a project already held, an org at its ceiling — and the
// server says which. A bare status code next to the button would name none of
// them.
if (!res.ok) {
const why = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(why?.error || `new machine → ${res.status}`);
}
return (await res.json()) as SandboxMachine;
};
/** Mint the single-use ticket for a sandbox's terminal. Spent on first use. */
export const terminalTicket = async (token: string, id: string): Promise<string> => {
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal/ticket`, {
+2 -41
View File
@@ -72,14 +72,12 @@ export function shellUrl(base: string, name: string): string {
}
}
/** Status → dot. The ONE map: session statuses first, then a machine's, then the
* two `withCloud` mints below for a box that has not linked yet.
/** Status → dot. The ONE map: session statuses first, then a machine's.
*
* `online | offline | draining` is exactly what the control plane sends
* (agents.TargetOnline/Offline/Draining). `busy` was here once and is sent by
* nothing; `draining` was missing and fell through to the offline grey, so a box
* being deliberately drained read as dead. A booting box would read as dead the
* same way, which is why `starting` and `linking` are amber here and not absent. */
* being deliberately drained read as dead. */
export const DOT: Record<string, string> = {
running: 'bg-emerald-500',
paused: 'bg-amber-500',
@@ -88,8 +86,6 @@ export const DOT: Record<string, string> = {
online: 'bg-emerald-500',
draining: 'bg-amber-500',
offline: 'bg-muted-foreground/40',
starting: 'bg-amber-500',
linking: 'bg-amber-500',
};
/**
@@ -137,38 +133,3 @@ export function rescued(
);
}
/**
* Cloud boxes, as ordinary machines.
*
* A launched box runs `hanzo link` exactly as a laptop does, so once it is up it
* arrives through the SAME registry as everything else and needs nothing here. It
* is only in the gap — launched, booting, not yet linked — that it exists to
* visor and not to the registry, and a box you are waiting on has to be visible
* or the launch button looks broken.
*
* So it is merged BY HOST and the registry wins: the moment the real machine
* registers, its own entry replaces the placeholder and there is nothing left of
* this. That direction matters — the registry knows whether a terminal is
* actually being served, and visor only knows what the provider was told.
*/
export function withCloud<T extends { machine: string; status: string; base?: string }>(
live: T[],
cloud: { name: string; state?: string }[],
make: (name: string, status: string) => T,
): T[] {
const known = new Set(live.map((h) => h.machine));
const booting = cloud
.filter((m) => m.name && !known.has(m.name))
// A destroyed box is not a pending one. Anything the provider calls off is
// simply gone, and showing it would be a machine you can never open.
.filter((m) => !/destroy|delete|terminat|archiv/i.test(m.state ?? ''))
.map((m) => make(m.name, /active|running/i.test(m.state ?? '') ? 'linking' : 'starting'));
return [...live, ...booting];
}
/** Whether a machine is one of the boxes above: on its way up, so it has no
* tunnel yet. The workspace has to let you open a pane on one anyway — the pane
* is where you watch it come up, and it turns into a terminal with nothing
* moving — so this is what tells it apart from a linked machine that simply
* serves no terminal, which there would be nothing to open. */
export const isPending = (status: string) => status === 'starting' || status === 'linking';
-50
View File
@@ -3,12 +3,10 @@ import {
DOT,
TERM_DEADLINE,
TERM_READY,
isPending,
isTermReady,
mintName,
rescued,
safeName,
withCloud,
shellUrl,
} from '@/lib/panes';
@@ -115,51 +113,3 @@ describe('a terminal announces itself, and silence is the rescue', () => {
expect(TERM_DEADLINE).toBeGreaterThanOrEqual(3000);
});
});
/**
* A cloud box is a machine that happens to have been started by us. It arrives
* through the ordinary registry the moment it links, so the only job here is the
* gap before that — and the only rule is that the registry wins.
*/
describe('a launched box is an ordinary machine', () => {
const make = (machine: string, status: string) => ({ machine, status });
it('shows a box that has not linked yet, so the launch is visible', () => {
expect(withCloud([], [{ name: 'cloud-1', state: 'new' }], make)).toEqual([
{ machine: 'cloud-1', status: 'starting' },
]);
});
it('lets the registry win once the box really registers', () => {
// The registry knows a terminal is being served; visor only knows what the
// provider was told. A duplicate pane for one box is the bug this prevents.
const live = [{ machine: 'cloud-1', status: 'online', base: 'https://x.share.hanzo.ai' }];
expect(withCloud(live, [{ name: 'cloud-1', state: 'active' }], make)).toEqual(live);
});
it('never offers a box the provider has destroyed', () => {
expect(withCloud([], [{ name: 'gone', state: 'destroyed' }], make)).toEqual([]);
});
it('keeps linked machines untouched', () => {
const live = [{ machine: 'ra', status: 'online' }];
expect(withCloud(live, [], make)).toEqual(live);
});
it('is pending exactly while it has no tunnel', () => {
// What the workspace admits without a `base`. Widening this to any status it
// does not recognise would let a linked machine serving no terminal be opened
// into a pane that waits forever.
expect(isPending('starting')).toBe(true);
expect(isPending('linking')).toBe(true);
expect(isPending('online')).toBe(false);
expect(isPending('offline')).toBe(false);
});
it('never reads as dead while it is coming up', () => {
// `draining` fell through to the offline grey once and a drained box read as
// dead. A booting one fails the same way if these are absent.
expect(DOT.starting).toBe(DOT.paused);
expect(DOT.linking).toBe(DOT.paused);
});
});
+64 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { API, sandboxTerminal } from '@/lib/api';
import { API, createSandbox, machineName, sandboxTerminal } from '@/lib/api';
/**
* A sandbox joins the workspace as a machine whose URL is MINTED, not
@@ -49,3 +49,66 @@ describe('the workspace mints once per pane', () => {
expect(src).toMatch(/refused\[id\] \?[\s\S]*?Reconnect/);
});
});
describe('a sandbox is named by its project', () => {
it('goes by the project, which is what its disk is keyed on', () => {
expect(machineName({ id: 'abcdef123456', status: 'running', project: 'tabs' })).toBe('tabs');
});
it('falls back to its id, so a projectless box is still openable', () => {
expect(machineName({ id: 'abcdef123456', status: 'running' })).toBe('box-abcdef');
});
});
/**
* Starting one. The server keeps ONE live sandbox per project and refuses the
* rest, so the name is picked against what is already live — which is the whole
* difference between a second machine and a second attempt at the first.
*/
describe('a new cloud machine', () => {
const calls: { url: string; init?: RequestInit }[] = [];
const answer = (live: unknown[], made: unknown, ok = true) => {
calls.length = 0;
global.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), init });
const body = init?.method === 'POST' ? made : { sandboxes: live };
return {
ok: init?.method === 'POST' ? ok : true,
status: ok ? 201 : 503,
json: async () => body,
};
}) as unknown as typeof fetch;
};
it('asks for a dev sandbox — the machine you shell into, not a scratch pod', async () => {
answer([], { id: 'b1', status: 'running', project: 'tabs' });
await createSandbox('t');
const post = calls.find((c) => c.init?.method === 'POST')!;
expect(post.url).toBe(`${API}/v1/sandboxes`);
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'dev', project: 'tabs' });
});
it('counts past the boxes already live, so pressing twice makes two', async () => {
answer(
[
{ id: 'b1', status: 'running', project: 'tabs' },
{ id: 'b2', status: 'running', project: 'tabs-2' },
],
{ id: 'b3', status: 'running', project: 'tabs-3' },
);
await createSandbox('t');
const post = calls.find((c) => c.init?.method === 'POST')!;
expect(JSON.parse(String(post.init!.body)).project).toBe('tabs-3');
});
it("carries the server's reason, because a bare status code names none of them", async () => {
answer([], { error: 'start sandbox: pod not running after 2m0s' }, false);
await expect(createSandbox('t')).rejects.toThrow('pod not running after 2m0s');
});
it('never sends the bearer to anything but the API', async () => {
answer([], { id: 'b1', status: 'running', project: 'tabs' });
await createSandbox('t');
for (const c of calls) expect(c.url.startsWith(API)).toBe(true);
});
});