feat(gateway,cli): GET /state/:bot; sdk/cli.ts state dumps over HTTP first

The chat CLI moved to gateway HTTP endpoints (98b28b1ca) but the state
CLI still opened a fresh observer WebSocket per invocation - the last
per-call TLS handshake in the split benchmark topology, and the source
of the remaining 'Failed to connect to wss://' errors there.

- gateway: GET /state/:bot answers from the last held frame with
  stateAge + botStatus (404 JSON when nothing has registered).
- cli.ts: try /state first with a bounded fetch (+1 retry), fall back
  to the observer socket only for gateways that predate the endpoint.
  Output unchanged.
- tests: endpoint + CLI end-to-end (no observer session left behind).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude committed 2026-08-17 10:52:08 -04:00
1 parent d80066c78a
commit ff60e8dee6
3 files changed
+160 -17

No files matched your search

+78 -17
View File
@@ -12,9 +12,64 @@
import { BotSDK, deriveGatewayUrl } from './index';
import { formatWorldState } from './formatter';
import type { BotWorldState } from './types';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
/** ws://host -> http://host, wss://host/path -> https://host/path (the gateway serves both on one port). */
function gatewayHttpBase(gatewayUrl: string): string {
return gatewayUrl.replace(/^ws(s?):\/\//, 'http$1://').replace(/\/$/, '');
}
/**
* Primary path: one bounded HTTP GET against the gateway's /state/:bot, which
* answers from the last frame it holds. No per-invocation WebSocket handshake
* (the thing that flakes over TLS tunnels), and a hard per-attempt timeout
* with one retry on network failure. Returns null when the gateway predates
* the endpoint (old gateways answer unknown paths with a text banner), so the
* caller can fall back to the observer socket.
*/
async function fetchStateViaHttp(
gatewayUrl: string,
username: string,
password: string,
timeout: number
): Promise<{ state: BotWorldState; stateAge: number } | null> {
const params = new URLSearchParams();
if (password) params.set('password', password);
const qs = params.toString();
const url = `${gatewayHttpBase(gatewayUrl)}/state/${encodeURIComponent(username)}${qs ? `?${qs}` : ''}`;
let res: Response | null = null;
let lastErr: unknown;
for (let attempt = 0; attempt < 2 && !res; attempt++) {
try {
res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
} catch (err) {
lastErr = err;
if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 500));
}
}
if (!res) {
console.error(`Error: Failed to reach gateway at ${url}`);
console.error(` ${(lastErr as any)?.message ?? lastErr}`);
process.exit(1);
}
if (!(res.headers.get('content-type') ?? '').includes('json')) return null;
const body: any = await res.json().catch(() => null);
if (res.status === 404 || !body?.state) {
console.error(`Error: No game state for '${username}' (gateway is fine - answered OK)`);
console.error(` Nothing is logged into the game as '${username}', so there's no state to report.`);
console.error(` This is also what you see after a server restart or when an idle client goes stale.`);
console.error(` Fix: open the web client and log in as '${username}', then re-run.`);
console.error(` (Scripts via sdk/runner.ts launch the client for you.)`);
if (body?.error && res.status !== 404) console.error(` ${body.error}`);
process.exit(1);
}
return { state: body.state as BotWorldState, stateAge: Number(body.stateAge) || 0 };
}
/**
* Connect, then wait for game state - reporting the two failure modes separately.
*
@@ -154,25 +209,31 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
const gatewayUrl = deriveGatewayUrl(server);
// 'observe', not the BotSDK default of 'control': this is a read-only dump, and the
// gateway pre-empts existing controllers when a new one connects. Connecting as a
// controller here would kill whatever script currently owns the bot — and pre-emption
// disables its auto-reconnect, so it would die permanently on a mere state check.
const sdk = new BotSDK({
botUsername: username,
password,
gatewayUrl,
connectionMode: 'observe',
autoReconnect: false,
autoLaunchBrowser: false,
readyTimeout: 0
});
const { state, stateAge } = await connectAndAwaitState(sdk, gatewayUrl, username, timeout);
// Try the gateway's HTTP state endpoint first; only gateways that predate
// it fall through to the observer WebSocket below.
let result = await fetchStateViaHttp(gatewayUrl, username, password, timeout);
let sdk: BotSDK | null = null;
if (!result) {
// 'observe', not the BotSDK default of 'control': this is a read-only dump, and the
// gateway pre-empts existing controllers when a new one connects. Connecting as a
// controller here would kill whatever script currently owns the bot — and pre-emption
// disables its auto-reconnect, so it would die permanently on a mere state check.
sdk = new BotSDK({
botUsername: username,
password,
gatewayUrl,
connectionMode: 'observe',
autoReconnect: false,
autoLaunchBrowser: false,
readyTimeout: 0
});
result = await connectAndAwaitState(sdk, gatewayUrl, username, timeout);
}
const { state, stateAge } = result;
if (flags.json) {
console.log(JSON.stringify(state, null, 2));
sdk.disconnect();
sdk?.disconnect();
process.exit(0);
}
@@ -188,7 +249,7 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
console.log(formatWorldState(state, stateAge));
sdk.disconnect();
sdk?.disconnect();
process.exit(0);
}
+52
View File
@@ -170,6 +170,24 @@ describe('gateway HTTP chat endpoints', () => {
expect(Date.now() - start).toBeLessThan(5000);
}, 10000);
test('GET /state/:bot returns the last frame with its age', async () => {
const res = await fetch(`${BASE}/state/tbot`);
expect(res.status).toBe(200);
const body: any = await res.json();
expect(body.state.player.name).toBe('tbot');
expect(body.state.inGame).toBe(true);
expect(typeof body.stateAge).toBe('number');
expect(body.botStatus).toBe('active');
});
test('GET /state for an unknown bot answers 404 JSON, not a banner', async () => {
const res = await fetch(`${BASE}/state/nobody`);
expect(res.status).toBe(404);
const body: any = await res.json();
expect(body.state).toBeNull();
expect(body.botStatus).toBe('dead');
});
test('POST /say rejects a missing text body without dispatching', async () => {
const res = await fetch(`${BASE}/say/tbot`, {
method: 'POST',
@@ -217,3 +235,37 @@ describe('chat CLI over HTTP', () => {
expect(Date.now() - start).toBeLessThan(20000);
}, 30000);
});
describe('state CLI over HTTP', () => {
async function runStateCli(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const proc = Bun.spawn({
cmd: ['bun', join(REPO_ROOT, 'sdk', 'cli.ts'), ...args, '--server', `localhost:${PORT}`],
cwd: REPO_ROOT,
stdout: 'pipe',
stderr: 'pipe'
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited
]);
return { exitCode, stdout, stderr };
}
test('dumps state end-to-end without an observer socket', async () => {
const before = ((await (await fetch(`${BASE}/status/tbot`)).json()) as any).observers.length;
const { exitCode, stdout } = await runStateCli(['tbot', 'state', '--json']);
expect(exitCode).toBe(0);
expect(JSON.parse(stdout).player.name).toBe('tbot');
const after = ((await (await fetch(`${BASE}/status/tbot`)).json()) as any).observers.length;
expect(after).toBe(before);
}, 20000);
test('explains a missing bot instead of hanging', async () => {
const start = Date.now();
const { exitCode, stderr } = await runStateCli(['nobody', 'state', '--timeout', '3000']);
expect(exitCode).toBe(1);
expect(stderr).toContain("No game state for 'nobody'");
expect(Date.now() - start).toBeLessThan(15000);
}, 20000);
});
+30
View File
@@ -846,6 +846,36 @@ const server = Bun.serve({
});
}
// Read the bot's last world state: GET /state/:username
//
// Same rationale as /chat: `sdk/cli.ts` state dumps used to open a
// fresh observer WebSocket per invocation, which is the last remaining
// per-call handshake over TLS tunnels ("Failed to connect to wss://…"
// in the split market runs). The gateway already holds the latest
// frame, so answer from it. `stateAge` lets the caller flag staleness;
// 404 when nothing has ever registered as this bot.
const stateMatch = url.pathname.match(/^\/state\/(.+)$/);
if (stateMatch && stateMatch[1] && req.method === 'GET') {
const username = decodeURIComponent(stateMatch[1]);
const auth = await authenticateHttpRequest(req, url, username);
if (!auth.success) {
return jsonResponse({ error: `Authentication failed: ${auth.error}` }, 401);
}
const botSession = botSessions.get(username);
if (!botSession || !botSession.lastState) {
return jsonResponse({
error: `No game state for '${username}' - nothing is logged into the game as that bot`,
botStatus: botSession ? getSessionStatus(botSession) : 'dead',
state: null
}, 404);
}
return jsonResponse({
state: botSession.lastState,
stateAge: Date.now() - botSession.lastStateReceivedAt,
botStatus: getSessionStatus(botSession)
});
}
// Send chat: POST /say/:username body: {"text": "...", "password"?: "...", "timeoutMs"?: n}
//
// Relays through the bot's game client like observer 'say' does, but as