Files
zeekayandHanzo Dev 4b3950e96a One wiring between a control plane and the workspace
The web app's page held 145 lines that were not about being a page: read
the registry, fold sessions and sandboxes into machines, mint a url per
pane, poll on the plane's own clock. With three hosts for the workspace
that is three answers to how often a machine goes stale, so it is a hook
now and the page is 22 lines.

What a host still owns is the token and where its cloud is. Each gets the
token its own way — a browser mints it with PKCE, the desktop already
holds the signed-in user's — and the hook has no business knowing which.

TerminalHost had two definitions after the first draft of this, so it
moved to the pure vocabulary both sides read. A lib module importing a
component module would invert the layering, and a second copy of the type
is two answers to what a machine is.

One defect fell out of the move: the signed-out screen showed the
REGISTRY's error, which is always null when there is no token — so a
refused sign-in said nothing at all. The two failures have different
remedies and now have different states.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-14 21:02:37 -07:00

186 lines
6.7 KiB
TypeScript

/**
* The machines you have, ready to hand to a workspace.
*
* This is the wiring between a control plane and the `Workspace` component, and
* it lived in the web app's page — which was fine while there was one host.
* There are three now (tabs.hanzo.ai, the desktop shell, hanzo.ai), and the
* wiring is the same React in each: read the registry, fold sessions and
* sandboxes into machines, mint a url per pane, poll on the plane's own clock.
* Copied three times it would be three sets of answers to how often a machine
* goes stale.
*
* WHAT A HOST STILL OWNS is the token and where its cloud is. Everything else
* is here. The token is passed in rather than fetched because each host gets it
* its own way — a browser mints it with PKCE, the desktop already holds the
* signed-in user's — and this module has no business knowing which.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
API,
type Class,
type Machine,
type SandboxMachine,
type Session,
createSandbox,
frameUrl,
grant,
machineName,
machines,
sandboxes,
sessions,
} from './api';
import { type Binding, type TerminalHost, shellUrl } from './panes';
/** Machines heartbeat every 30s and the plane calls one stale after 90s, so
* polling faster than the fact changes would only cost requests. */
const BEAT = 30_000;
export interface Fleet {
/** The machines, folded. Empty until the first read lands. */
hosts: TerminalHost[];
/** Whether a read has landed at all — which is a different fact from having
* no machines, and the one a caller needs to avoid claiming the second. */
read: boolean;
/** Why the registry could not be read, if it could not. */
error: string | null;
/** A fresh url for a pane. The workspace holds no token, so this does. */
mint: (host: TerminalHost, what: Binding) => Promise<string>;
/** Start a cloud box of one class and answer the name it goes by here. */
launch: (kind: Class) => Promise<string>;
/** Read the registry again now. */
refresh: () => void;
}
export function useFleet(token: string | null, origin: string = API): Fleet {
const [data, setData] = useState<{
m: Machine[];
s: Session[];
b: SandboxMachine[];
} | null>(null);
const [error, setError] = useState<string | null>(null);
const read = useCallback(
async (t: string) => {
try {
// The registry is where machines and terminals come from. Sandboxes are
// a SECOND read against the same plane: caught rather than reported,
// because their absence costs those panes and nothing else, and a
// banner about boxes you may not have is noise on a workspace that
// still works.
const [m, s, b] = await Promise.all([
machines(t, origin),
sessions(t, origin),
sandboxes(t, origin).catch(() => []),
]);
setData({ m, s, b });
setError(null);
} catch (e) {
// A registry that cannot be read is reported as such. Rendering an
// empty workspace would claim "no machines", which is a different and
// wrong thing.
setError(e instanceof Error ? e.message : 'could not reach the registry');
}
},
[origin],
);
const refresh = useCallback(() => {
if (token) void read(token);
}, [token, read]);
useEffect(() => {
if (!token) return;
void read(token);
const beat = setInterval(() => void read(token), BEAT);
return () => clearInterval(beat);
}, [token, read]);
/**
* Machines that can serve shells, and the tunnel each one's terminals live on.
*
* A terminal url is a fact about the MACHINE — one link, one ttyd, one tunnel
* — so it is read from whichever of its sessions published one, rather than
* treated as a property of that session. The workspace then names a shell per
* pane with `?arg=`, so one link serves many.
*/
const hosts = useMemo<TerminalHost[]>(() => {
if (!data) return [];
const base = new Map<string, string>();
for (const s of data.s) {
if (!s.terminal || !s.host) continue;
if (s.status !== 'running' && s.status !== 'paused') continue;
if (!base.has(s.host)) base.set(s.host, s.terminal);
}
const out = new Map<string, TerminalHost>();
for (const m of data.m) {
const key = m.host || m.label || m.id;
out.set(key, { machine: key, base: base.get(key), status: m.status, label: m.capacity });
}
for (const [host, url] of base) {
if (!out.has(host)) out.set(host, { machine: host, base: url, status: 'online' });
}
// A sandbox has no tunnel to publish — its terminal url is MINTED per open
// (a 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 = machineName(s);
if (!out.has(key)) {
out.set(key, {
machine: key,
sandbox: s.id,
screen: s.class === 'desktop',
status: 'online',
});
}
}
return [...out.values()];
}, [data]);
/**
* A fresh url for a pane, minted where the token lives so the workspace stays
* credential-free.
*
* ONE ACT, WHOEVER SERVES THE PAGE. A sandbox's terminal is hosted by cloud
* and opened by a single-use ticket; a linked machine's is served by the
* machine over its own tunnel, and what that tunnel wants is a session for
* the identity already held here. Both are "ask with the token, then frame
* the answer", which is why the workspace does not know which kind of machine
* a pane is bound to.
*/
const mint = useCallback(
async (host: TerminalHost, what: Binding) => {
if (!token) throw new Error('signed out');
if (host.sandbox) {
return what.kind === 'screen'
? frameUrl(token, host.sandbox, 'screen', undefined, origin)
: frameUrl(
token,
host.sandbox,
'terminal',
what.kind === 'shell' ? what.shell.name : undefined,
origin,
);
}
if (!host.base || what.kind !== 'shell') throw new Error('this machine serves no terminal');
await grant(token, host.base);
return shellUrl(host.base, what.shell.name);
},
[token, origin],
);
const launch = useCallback(
async (kind: Class) => {
if (!token) throw new Error('signed out');
const box = await createSandbox(token, kind, origin);
await read(token);
return machineName(box);
},
[token, origin, read],
);
return { hosts, read: data !== null, error, mint, launch, refresh };
}