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

298 lines
12 KiB
TypeScript

/**
* What a pane SHOWS, kept apart from where it sits.
*
* A pane used to be identified by a session id, which braided two questions into
* one value: *where is this on screen* and *what is running in it*. That braid is
* why the workspace could not open anything — a split had to find an unused
* session, so with one session there was nothing to split into and both buttons
* sat disabled under a sentence explaining why. It is also why a layout collapsed
* when a run finished: the pane's identity had ended.
*
* A pane id is minted here and means only "this box". What it shows is a
* BINDING — a machine and a shell name — and the URL is derived from the two.
*
* ONE LINK SERVES MANY SHELLS. `hanzo link` runs ttyd with `--url-arg`, so the
* query names a tmux session: `?arg=build` is a shell called build, and no arg is
* the default. `tmux new -A` attaches or creates, which means opening a shell is
* a URL and closing a pane loses nothing — reopen the same name and the
* scrollback is still there. There is no spawn endpoint because none is needed.
*/
/** A machine that can serve shells: its name and the tunnel its terminals live on. */
export interface TerminalHost {
machine: string;
/** The share URL `hanzo link` published. Absent ⇒ nothing to frame. */
base?: string;
/** A sandbox's id. Its URLs are MINTED per open (single-use ticket) rather
* than published, so a sandbox host has this and no base. */
sandbox?: string;
/** Whether this machine has a DISPLAY to watch. It is the machine's own
* answer — a `desktop` sandbox runs an X server and a VNC server, the other
* classes have neither — and never inferred from a name. */
screen?: boolean;
status: string;
label?: string;
}
/** A shell: which machine, and which tmux session on it. */
export interface Shell {
machine: string;
/** `[A-Za-z0-9_-]{1,32}` — the machine sanitises it again, but a UI must never
* send a name it did not construct. */
name: string;
}
export type Binding =
| { kind: 'shell'; shell: Shell }
/** The machine's SCREEN — its X display, as pixels. No name, because a
* machine has one display where it has as many shells as you ask for. */
| { kind: 'screen'; machine: string }
/**
* A WEB PAGE, at a url.
*
* The one binding that is not a machine's face, so there is nothing to link,
* nothing to start and nothing to mint: the url IS the binding, and it
* survives a reload the way a shell name does.
*/
| { kind: 'page'; url: string; }
/** Split made, machine not chosen yet. */
| { kind: 'empty' }
/**
* A machine is being STARTED for this pane, and does not exist yet.
*
* The pane is on screen from the click, because the wait is the thing worth
* watching and it is the longest part: provisioning a cloud machine takes
* tens of seconds, and a spinner on the button that caused it leaves the
* workspace looking untouched for all of them. `want` is how the machine will
* be shown the moment it answers.
*/
| { kind: 'starting'; want: 'shell' | 'screen' }
/** No machine came. The pane keeps the reason, and whether the reason was
* MONEY — because that one has a remedy the others do not, and a person out
* of credit still has a machine of their own to bring. */
| { kind: 'failed'; why: string; unfunded?: boolean }
/** The machine went away. The pane STAYS, because tmux is still holding the
* session and the layout is the reader's, not a function of someone's uptime. */
| { kind: 'gone'; shell: Shell };
/** What a pane calls itself: the machine, then which of its faces this is.
* One function, so the header, the frame's title and the phone's pager cannot
* disagree about what a person is looking at. */
export function label(b: Binding): string {
switch (b.kind) {
case 'shell':
return `${b.shell.machine} · ${b.shell.name}`;
case 'gone':
return `${b.shell.machine} · ${b.shell.name}`;
case 'screen':
return `${b.machine} · desk`;
case 'page':
return host(b.url) || 'New tab';
case 'starting':
return b.want === 'screen' ? 'Starting a desktop' : 'Starting a machine';
case 'failed':
return 'Could not start';
default:
return 'New shell';
}
}
/**
* What a saved pane becomes on the way back in.
*
* A launch does not survive the page that asked for it: the promise died with
* the tab, so a restored `starting` would spin for a machine nobody is waiting
* on, and a restored `failed` would state a reason about a moment that has
* passed. Both come back as the pane that asks — by then the machine may well
* have finished starting, and it is in the list.
*/
export function restore(b: Binding): Binding {
return b.kind === 'starting' || b.kind === 'failed' ? { kind: 'empty' } : b;
}
/** The machine a pane is bound to, whichever way it is looking at it. */
export function machineOf(b: Binding): string | null {
switch (b.kind) {
case 'shell':
case 'gone':
return b.shell.machine;
case 'screen':
return b.machine;
default:
return null;
}
}
/** The tmux session a machine's first pane opens.
*
* It was `hanzo`, which put the company's name where the SHELL's goes and made
* a pane read `tabs · hanzo` — two nouns, neither of them obviously the machine
* or the session. Numbered from one, a pane says which box and which of its
* shells, and nothing has to be recognised.
*
* Only NEW shells take this name: a pane that is already open was saved with
* its binding and comes back on the session it was on, so nothing running is
* left behind by the rename. */
export const DEFAULT_SHELL = 'shell-1';
const SAFE = /[^A-Za-z0-9_-]/g;
/** Reduce a name to what the machine will accept, so the two never disagree. */
export function safeName(raw: string): string {
const n = raw.replace(SAFE, '').slice(0, 32);
return n || DEFAULT_SHELL;
}
/** The next unused shell name for a machine, given what is already open on it. */
export function mintName(taken: readonly string[]): string {
for (let i = 1; i <= taken.length + 1; i++) {
const n = `shell-${i}`;
if (!taken.includes(n)) return n;
}
return DEFAULT_SHELL;
}
/**
* The URL a pane frames.
*
* Built with URLSearchParams and exactly one `arg`, never by concatenation:
* `--url-arg` appends EVERY `arg=` in the query to the command's argv, so a
* second one is a second argument to a shell running on someone's machine.
*/
export function shellUrl(base: string, name: string): string {
try {
const u = new URL(base);
u.searchParams.set('arg', safeName(name));
return u.toString();
} catch {
return base; // a base we cannot parse is one we must not decorate
}
}
/**
* What a typed address becomes, or null if it cannot become a page.
*
* Two jobs, and the second is the one that matters. A person types
* `news.ycombinator.com` and means https, so a bare host gets a scheme. And a
* typed string reaches an `iframe src`, where `javascript:` and `data:` are
* script in THIS document rather than a page in the frame — so the scheme is
* checked against http and https and nothing else is admitted. Refusing is the
* answer: a browser pane that quietly loaded something else would be worse than
* one that says it cannot.
*/
export function web(typed: string): string | null {
const raw = typed.trim();
if (!raw) return null;
const guessed = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw) ? raw : `https://${raw}`;
try {
const u = new URL(guessed);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null;
} catch {
return null;
}
}
/** The host of a url, for a tab to name itself by. `www.` comes off because a
* tab strip is narrow and the four characters carry nothing. */
export function host(url: string): string {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return '';
}
}
/**
* Whether a pane is one of OURS, and so owes us a word that it came up.
*
* The readiness contract below is a conversation between this workspace and
* pages we serve. A page on the open web is in no such conversation: it will
* never post `hanzo-term`, so a rescue keyed on silence covers every browser
* pane six seconds after it loads — offering to reconnect a site that is
* already on screen and working.
*
* So silence means "did not come up" only for a pane that promised to speak.
*/
export function proves(b: Binding): boolean {
return b.kind === 'shell' || b.kind === 'screen' || b.kind === 'gone';
}
/** Status → the colour of the 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.
*
* These are the design system's own state colours, read as custom properties.
* The one exception is a machine on its way somewhere — design publishes no
* warning rung, so the amber it was already drawing is stated here rather than
* bent into a token that means something else. */
export const AMBER = '#f59e0b';
export const DOT: Record<string, string> = {
running: 'var(--state-online)',
paused: AMBER,
done: 'var(--text-disabled)',
error: 'var(--destructive)',
online: 'var(--state-online)',
draining: AMBER,
offline: 'var(--text-disabled)',
};
/**
* The readiness contract — BOTH halves, in one place.
*
* `READY` are the `source` values our framed pages post to their parent once
* they are up: the terminal's when xterm has opened (hanzo/cli
* `assets/term/client.js`, cloud `apps/sandbox/term/page.html`), the screen's
* when noVNC has connected (cloud `apps/sandbox/screen/page.html`). They live
* here rather than in the component because they are wire values shared with
* another repository: one side changing one silently is a workspace where every
* pane of that kind looks dead.
*
* Two names and one meaning, on purpose. Each page says WHAT it is, because
* that costs nothing and a page that lied about itself would be worse; what a
* parent asks is only whether the thing it framed came up, so the question has
* one answer for both.
*
* The rule this enables is small and the reason for it is not. A parent cannot
* tell a healthy cross-origin frame from a refused one — the browser substitutes
* its own error document for a refusal, and BOTH throw on `contentDocument`. So
* a terminal is present only when it SAYS so, and every way of failing (an OAuth
* gate, a `frame-ancestors` refusal, a dead tunnel, an offline machine) collapses
* to the same silence and the same answer.
*/
export const READY = ['hanzo-term', 'hanzo-screen'];
/** How long a pane waits to hear from what it framed before offering the way
* out. Long enough that a rescue never flashes over a terminal that is merely
* still connecting, which would be its own defect. */
export const DEADLINE = 6000;
/** Whether a posted message is one of our pages announcing itself. */
export function isReady(data: unknown): boolean {
return (
typeof data === 'object' &&
data !== null &&
READY.includes((data as { source?: unknown }).source as string)
);
}
/** Panes that should show the way out: waited past the deadline, never heard
* from, and OURS to hear from (see `proves`). A pane that has not waited yet is
* absent, not false — it is still loading, and nothing should be drawn over it. */
export function rescued(
waited: Record<string, boolean>,
alive: Record<string, boolean>,
proving: Record<string, boolean>,
): Record<string, boolean> {
return Object.fromEntries(
Object.keys(waited)
.filter((id) => waited[id] && proving[id])
.map((id) => [id, !alive[id]]),
);
}