mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
tabs: a money refusal is an object, and it has a remedy
The fleet answers a refusal two ways. Most doors send `{error:"..."}`; MONEY
sends `{error:{code,message}}` — 402 insufficient_balance, 402
spend_cap_exceeded, 503 balance_unavailable — because every Hanzo surface reads
that one contract by `error.code`. This read only the flat shape, so the nested
object was coerced by `new Error(obj)` and the ONE refusal a person can act on
rendered as `[object Object]` beside a button that had just failed.
It reads both now, and keeps the CODE. That is the part worth keeping: being out
of credit wants a different answer from a full node or an image that will not
pull, and telling them apart by matching the sentence breaks the first time
somebody rewords it.
So the pane says the thing the person can do RIGHT NOW: a cloud machine is ours
and costs credit, one of theirs costs nothing, and `hanzo link` puts it in this
same workspace. No billing link — tabs has no backend and no billing surface, and
the workspace's own gate forbids opening another domain in a tab, which is the
rule that caught the first draft of this and was right to.
Latent until now, because `SANDBOX_FEE_CENTS` is unset and the gate is skipped
whenever the fee is zero, so nothing has ever been refused for money. It would
have surfaced on the first launch after somebody set a price.
The `answer` helper takes a status, so a 402 can be driven rather than described.
This commit is contained in:
@@ -51,6 +51,11 @@ import {
|
||||
stableOrder,
|
||||
stackFor,
|
||||
} from '@/lib/tiles';
|
||||
// One import, and it is a PURE READING of an error — no origin, no token, no
|
||||
// request. The workspace still holds no credential; what it gains is the
|
||||
// platform's own word for "no money", which is the one failure whose remedy
|
||||
// differs from every other.
|
||||
import { unfunded } from '@/lib/api';
|
||||
import {
|
||||
DEADLINE,
|
||||
DOT,
|
||||
@@ -274,6 +279,7 @@ export function Workspace({
|
||||
settle(id, {
|
||||
kind: 'failed',
|
||||
why: e instanceof Error ? e.message : 'could not start a machine',
|
||||
unfunded: unfunded(e),
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -760,6 +766,19 @@ export function Workspace({
|
||||
) : b.kind === 'failed' ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<p className="max-w-xs text-xs text-muted-foreground">{b.why}</p>
|
||||
{/* Out of credit is the ONE failure here with a remedy the
|
||||
person already holds: a cloud machine is ours and costs
|
||||
money, a linked one is theirs and costs nothing, and
|
||||
tabs drives both identically. Saying only "insufficient
|
||||
balance" beside a Close button reads as a dead end when
|
||||
the workspace still works. */}
|
||||
{b.unfunded ? (
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
A cloud machine is ours and costs credit. One of yours costs nothing —
|
||||
run <code className="font-mono text-foreground">hanzo link</code> on it
|
||||
and its shell opens here.
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => doClose(id)}
|
||||
|
||||
+46
-8
@@ -27,6 +27,47 @@ export interface Session {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal, carrying the platform's own word for it.
|
||||
*
|
||||
* `code` is what the caller can BRANCH on — being out of credit wants a
|
||||
* different answer from a full node, and telling them apart by reading the
|
||||
* sentence would break the first time the sentence is reworded.
|
||||
*/
|
||||
export class Refusal extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'Refusal';
|
||||
}
|
||||
}
|
||||
|
||||
/** Out of credit, or past the org's spend cap. Both are answered the same way —
|
||||
* put money on it, or bring a machine of your own — so they are one question. */
|
||||
export const unfunded = (e: unknown): boolean =>
|
||||
e instanceof Refusal && (e.code === 'insufficient_balance' || e.code === 'spend_cap_exceeded');
|
||||
|
||||
/**
|
||||
* Read a refusal off a response, in EITHER shape the fleet sends.
|
||||
*
|
||||
* The money wire answers `{error:{code,message}}` — an OBJECT — while other
|
||||
* doors answer `{error:"..."}`. Reading only the flat one coerced the nested
|
||||
* object into a string, so the one refusal a person can actually act on
|
||||
* rendered as `[object Object]` beside a button that had just failed.
|
||||
*/
|
||||
async function refusal(res: Response, fallback: string): Promise<Refusal> {
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { error?: string | { code?: string; message?: string } }
|
||||
| null;
|
||||
const e = body?.error;
|
||||
return typeof e === 'object' && e !== null
|
||||
? new Refusal(res.status, e.code ?? '', e.message || fallback)
|
||||
: new Refusal(res.status, '', e || fallback);
|
||||
}
|
||||
|
||||
async function read<T>(path: string, token: string): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -120,14 +161,11 @@ export const createSandbox = async (token: string, kind: Class = 'dev'): Promise
|
||||
body: JSON.stringify({ class: kind, 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}`);
|
||||
}
|
||||
// Starting a machine fails for reasons a person can act on — no credit, 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, and the CODE is what lets the pane offer the right remedy.
|
||||
if (!res.ok) throw await refusal(res, `new machine → ${res.status}`);
|
||||
return (await res.json()) as SandboxMachine;
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -43,8 +43,10 @@ export type Binding =
|
||||
* be shown the moment it answers.
|
||||
*/
|
||||
| { kind: 'starting'; want: 'shell' | 'screen' }
|
||||
/** No machine came. The pane keeps the reason, next to the space it was for. */
|
||||
| { kind: 'failed'; why: string }
|
||||
/** 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 };
|
||||
|
||||
+38
-3
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { API, createSandbox, frameUrl, grant, machineName } from '@/lib/api';
|
||||
import { API, Refusal, createSandbox, frameUrl, grant, machineName, unfunded } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* A sandbox joins the workspace as a machine whose URLs are MINTED, not
|
||||
@@ -164,14 +164,14 @@ describe('a sandbox is named by its project', () => {
|
||||
*/
|
||||
describe('a new cloud machine', () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const answer = (live: unknown[], made: unknown, ok = true) => {
|
||||
const answer = (live: unknown[], made: unknown, ok = true, status = ok ? 201 : 503) => {
|
||||
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,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
@@ -216,6 +216,41 @@ describe('a new cloud machine', () => {
|
||||
await expect(createSandbox('t')).rejects.toThrow('pod not running after 2m0s');
|
||||
});
|
||||
|
||||
// The refusal a person can actually act on, and the one the fleet spells
|
||||
// DIFFERENTLY from every other: money answers a NESTED {error:{code,message}}
|
||||
// where other doors answer a flat {error:"..."}. Reading only the flat shape
|
||||
// coerced that object into a string, so an org out of credit was told
|
||||
// "[object Object]" beside a button that had just failed.
|
||||
it('reads the money refusal, which is an object and not a sentence', async () => {
|
||||
answer(
|
||||
[],
|
||||
{ error: { code: 'insufficient_balance', message: 'Add credit to start a machine' } },
|
||||
false,
|
||||
402,
|
||||
);
|
||||
const e = await createSandbox('t').catch((x) => x);
|
||||
expect(e).toBeInstanceOf(Refusal);
|
||||
expect(e.message).toBe('Add credit to start a machine');
|
||||
expect(e.message).not.toContain('object Object');
|
||||
expect(e.code).toBe('insufficient_balance');
|
||||
expect(e.status).toBe(402);
|
||||
// The whole point of carrying the CODE: the pane branches on it rather than
|
||||
// on the sentence, which is free to be reworded.
|
||||
expect(unfunded(e)).toBe(true);
|
||||
});
|
||||
|
||||
it('a spend cap is answered the same way — put money on it, or bring a machine', async () => {
|
||||
answer([], { error: { code: 'spend_cap_exceeded', message: 'Monthly cap reached' } }, false, 402);
|
||||
expect(unfunded(await createSandbox('t').catch((x) => x))).toBe(true);
|
||||
});
|
||||
|
||||
it('every other refusal is NOT a money one, so it offers no remedy it cannot keep', async () => {
|
||||
answer([], { error: 'start sandbox: pod not running after 2m0s' }, false, 503);
|
||||
const e = await createSandbox('t').catch((x) => x);
|
||||
expect(e.message).toContain('pod not running');
|
||||
expect(unfunded(e)).toBe(false);
|
||||
});
|
||||
|
||||
it('never sends the bearer to anything but the API', async () => {
|
||||
answer([], { id: 'b1', status: 'running', project: 'cloud-1' });
|
||||
await createSandbox('t');
|
||||
|
||||
Reference in New Issue
Block a user