Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workbench-gate-diagnostic-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Workbench connection gate and Overview rebuild alert: show the foreground diagnostic code and message — `AB8003 — Origin http://localhost:3000 is not allowed by the foreground server at http://127.0.0.1:3100. Open http://127.0.0.1:3100 instead, or start agent-bundle dev with --workbench-dev-origin http://localhost:3000 to allow this origin.` — with an `(HTTP <status>)` suffix only when the foreground response itself failed, instead of the misleading `Workbench request failed with HTTP 200.` (#589)
21 changes: 10 additions & 11 deletions packages/workbench/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ import { RoutesPage } from './routes/routes-page.tsx';
import { overviewFor } from './overview-model.ts';
import { downloadBlob, errorMessage as messageFrom } from './client-helpers.ts';
import { BundleWorkflow, HostAdoptionSection, StateMark } from './overview-page.tsx';
import { ProjectClient, type ProjectConnectionState } from './project-client.ts';
import { projectFailureText, ProjectClient, type ProjectConnectionState } from './project-client.ts';
import { SkillClient } from './skill-client.ts';
import { SkillsPage } from './skills-page.tsx';
import {
generalWorkbenchPages,
loadWorkbenchCapabilities,
type WorkbenchCapabilities,
} from './workbench-capabilities.ts';
import type { WorkbenchPage as GeneralWorkbenchPage } from './workbench-screen.tsx';
import { ConnectionGate, type WorkbenchPage as GeneralWorkbenchPage } from './workbench-screen.tsx';
import { RuntimeClient, type RuntimeBootstrap } from './runtime-client.ts';
import {
createRuntimeEventBuffer,
Expand Down Expand Up @@ -101,6 +101,9 @@ const sourceFor = (diagnostic: Diagnostic): string =>
const errorMessage = (reason: unknown): string =>
messageFrom(reason, 'Foreground project state could not be refreshed.');

const connectionFailure = (reason: unknown): string =>
projectFailureText(reason, 'Foreground project state could not be refreshed.');

const activeEpochFor = (status: ProjectStatus) =>
status.artifact.state === 'missing' ? undefined : status.artifact.activeEpoch;

Expand Down Expand Up @@ -388,7 +391,7 @@ const Overview = ({ capabilities, changedFiles, client, connectionError, onNavig
try {
onStatus(await client.rebuild());
} catch (reason) {
setError(messageFrom(reason, 'Rebuild request could not be completed.'));
setError(projectFailureText(reason, 'Rebuild request could not be completed.'));
} finally {
setRebuilding(false);
}
Expand Down Expand Up @@ -866,7 +869,7 @@ const Workbench = () => {
mcpControllerRef.current?.close() ?? Promise.resolve(),
]);
const failure = results.find((result) => result.status === 'rejected');
if (failure?.status === 'rejected') setConnectionError(errorMessage(failure.reason));
if (failure?.status === 'rejected') setConnectionError(connectionFailure(failure.reason));
mcpAppClient.current?.resetRuntimeForForegroundReplacement();
handoffCoordinator.current = undefined;
mcpPreviewDeparture.current = undefined;
Expand Down Expand Up @@ -1243,11 +1246,11 @@ const Workbench = () => {
}
},
(reason) => {
if (mounted) setConnectionError(errorMessage(reason));
if (mounted) setConnectionError(connectionFailure(reason));
},
(event) => { runtimeEvents.receive(event); },
).catch((reason: unknown) => {
if (mounted) setConnectionError(errorMessage(reason));
if (mounted) setConnectionError(connectionFailure(reason));
});
return () => {
mounted = false;
Expand Down Expand Up @@ -1345,11 +1348,7 @@ const Workbench = () => {
}, [mcpController]);
useEffect(() => mcpController.subscribe(setMcpModel), [mcpController]);

const connectionGate = connection.state === 'connected' ? undefined : <main aria-live="polite" className="connection-recovery loading-state">
<h1>{connection.state === 'unavailable' ? 'Foreground connection unavailable' : 'Foreground connection reconnecting'}</h1>
<p>{connection.state === 'unavailable' ? 'Waiting for the foreground server to recover.' : 'Connecting to the foreground server.'}</p>
{connectionError === undefined ? undefined : <p role="alert">{connectionError}</p>}
</main>;
const connectionGate = connection.state === 'connected' ? undefined : <ConnectionGate error={connectionError} state={connection.state} />;
const withConnectionGate = (content: ReactNode): ReactNode => <>
<div className="connection-content" inert={connectionGate === undefined ? undefined : true} key={connection.generation}>{content}</div>
{connectionGate}
Expand Down
26 changes: 23 additions & 3 deletions packages/workbench/src/mcp/mcp-route-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,11 @@ export class ForegroundRouteClient implements ForegroundRequestAuthority {
browserOrigin !== undefined && browserOrigin !== 'null' && browserOrigin !== body.origin &&
devOrigins?.includes(browserOrigin) !== true
) {
throw new ForegroundRouteClientError('AB8003', 'Foreground session bootstrap origin does not match this browser.', response.status);
throw new ForegroundRouteClientError(
'AB8003',
`Origin ${browserOrigin} is not allowed by the foreground server at ${body.origin}. Open ${body.origin} instead, or start agent-bundle dev with --workbench-dev-origin ${browserOrigin} to allow this origin.`,
response.status,
);
}
const previous = this.#snapshot;
const generation = previous === undefined
Expand Down Expand Up @@ -699,20 +703,36 @@ export class ForegroundRouteClientError extends Error {
readonly code: string;
readonly details: unknown | undefined;
readonly phase: string | undefined;
/**
* HTTP status of the failed foreground response (`fromResponse`); `undefined`
* when the client constructed the failure itself — a refused 200 body, a
* superseded or invalidated session — and `status` is only nominal.
*/
readonly responseStatus: number | undefined;
readonly status: number;

constructor(code: string, message: string, status: number, options: Readonly<{ readonly details?: unknown; readonly phase?: string }> = {}) {
constructor(
code: string,
message: string,
status: number,
options: Readonly<{ readonly details?: unknown; readonly phase?: string; readonly responseStatus?: number }> = {},
) {
super(message);
this.name = 'ForegroundRouteClientError';
this.code = code;
this.details = options.details;
this.phase = options.phase;
this.responseStatus = options.responseStatus;
this.status = status;
}

static fromResponse(body: unknown, status: number): ForegroundRouteClientError {
const detail = diagnostic(body, status);
return new ForegroundRouteClientError(detail.code, detail.message, status, detail);
return new ForegroundRouteClientError(detail.code, detail.message, status, {
details: detail.details,
phase: detail.phase,
responseStatus: status,
});
}
}

Expand Down
47 changes: 41 additions & 6 deletions packages/workbench/src/project-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';

import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts';
import { exactKeys, isRecord } from './client-helpers.ts';
import { errorMessage, exactKeys, isRecord } from './client-helpers.ts';
import {
freezeJsonValue,
type ArtifactEpoch,
Expand Down Expand Up @@ -68,14 +68,35 @@ interface QueuedProjectEvent {

export class ProjectClientError extends Error {
readonly code: string | undefined;

constructor(message: string, code?: string) {
/**
* Status of the failed foreground response (4xx/5xx); absent when the client
* constructed the failure itself — it refused an HTTP 200 bootstrap body, or
* the session was superseded or invalidated while a request was in flight.
*/
readonly status: number | undefined;

constructor(message: string, code?: string, status?: number) {
super(message);
this.name = 'ProjectClientError';
this.code = code;
this.status = status;
}
}

/**
* The one-line account of a project client failure the connection gate, the
* topbar, and the Overview rebuild alert show: `<code> — <message> (HTTP <status>)`,
* omitting the parts a failure lacks. Other errors keep their message; a
* hostile reason falls back.
*/
export const projectFailureText = (reason: unknown, fallback: string): string => {
const message = errorMessage(reason, fallback);
if (!(reason instanceof ProjectClientError)) return message;
const code = reason.code === undefined ? '' : `${reason.code} — `;
const status = reason.status === undefined ? '' : ` (HTTP ${reason.status})`;
return `${code}${message}${status}`;
};

const projectEventTypes = [
'artifact.available',
'artifact.status',
Expand Down Expand Up @@ -241,7 +262,7 @@ const projectStatusResponse = (value: unknown): ProjectStatusResponse => {

const projectError = (error: unknown): ProjectClientError | unknown =>
error instanceof ForegroundRouteClientError
? new ProjectClientError(`Workbench request failed with HTTP ${error.status}.`, error.code)
? new ProjectClientError(error.message, error.code, error.responseStatus)
: error;

const isSequence = (value: unknown): value is number =>
Expand Down Expand Up @@ -344,6 +365,7 @@ export class ProjectClient {
#eventRefreshQueued = false;
#highestQueuedEventId = -1;
#lastEventId = 0;
#lastReportedFailure: string | undefined;
#lastSourceChangeSequence = -1;
#errorListener: ProjectClientErrorListener | undefined;
#listener: ((status: ProjectStatus) => void) | undefined;
Expand Down Expand Up @@ -533,8 +555,9 @@ export class ProjectClient {
source.addEventListener('open', () => { void this.#refreshRecoveredSource(version); });
this.#setConnection({ generation: snapshot.generation, instanceId: snapshot.instanceId, state: 'connecting' });
return;
} catch {
} catch (error) {
if (this.#closed || recoveryVersion !== this.#recoveryVersion) return;
this.#reportRecoveryFailure(projectError(error));
await this.#retryDelay(retryDelayMilliseconds);
}
}
Expand All @@ -555,7 +578,7 @@ export class ProjectClient {
this.#eventSource = undefined;
source.close();
this.#setConnection({ ...this.#connection, state: 'unavailable' });
this.#reportError(error);
this.#reportError(projectError(error));
this.#startRecovery(version);
}
}
Expand Down Expand Up @@ -718,13 +741,25 @@ export class ProjectClient {

#reportError(reason: unknown): void {
if (this.#closed) return;
this.#lastReportedFailure = projectFailureText(reason, '');
try {
this.#errorListener?.(reason);
} catch {
// Consumer callbacks must not reintroduce an unhandled background rejection.
}
}

/**
* Recovery retries every {@link retryDelayMilliseconds}; a failed attempt is
* reported only when its line differs from the last report, so the gate moves
* from `Foreground project event stream disconnected.` to the refusal that
* keeps recovery from completing (e.g. `AB8003`) without flooding the listener.
*/
#reportRecoveryFailure(reason: unknown): void {
if (projectFailureText(reason, '') === this.#lastReportedFailure) return;
this.#reportError(reason);
}

#publishEvent(event: ProjectEventMessage): void {
const listeners = [
...(this.#eventListener === undefined ? [] : [this.#eventListener]),
Expand Down
12 changes: 12 additions & 0 deletions packages/workbench/src/workbench-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { type ReactNode } from 'react';

import type { ProjectConnectionPhase } from './project-client.ts';

export type WorkbenchPage = 'artifacts' | 'comparisons' | 'evals' | 'hooks' | 'hosts' | 'lifecycles' | 'logs' | 'mcp' | 'overview' | 'playground' | 'routes' | 'skills';

interface NavigationItem {
Expand Down Expand Up @@ -68,6 +70,16 @@ export const Topbar = ({ connectionError }: { readonly connectionError?: string
</span>
</header>;

/** Overlays the Workbench while the foreground connection is not `connected`; `error` is the `projectFailureText` line. */
export const ConnectionGate = ({ error, state }: {
readonly error?: string;
readonly state: Exclude<ProjectConnectionPhase, 'connected'>;
}) => <main aria-live="polite" className="connection-recovery loading-state">
<h1>{state === 'unavailable' ? 'Foreground connection unavailable' : 'Foreground connection reconnecting'}</h1>
<p>{state === 'unavailable' ? 'Waiting for the foreground server to recover.' : 'Connecting to the foreground server.'}</p>
{error === undefined ? undefined : <p role="alert">{error}</p>}
</main>;

export const Navigation = ({ onNavigate, page, pages }: {
readonly onNavigate: (page: WorkbenchPage) => void;
readonly page: WorkbenchPage;
Expand Down
Loading
Loading