Skip to content

fix(workbench): show the diagnostic code on the connection gate; deterministic runtime-owner assertion - #589

Merged
ScriptedAlchemy merged 5 commits into
mainfrom
fix/workbench-gate-diagnostic-code
Sep 5, 2026
Merged

fix(workbench): show the diagnostic code on the connection gate; deterministic runtime-owner assertion#589
ScriptedAlchemy merged 5 commits into
mainfrom
fix/workbench-gate-diagnostic-code

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes the two follow-ups left by #581 (fb56fa06a). Workbench-only: packages/agent-bundle/src is untouched.

Problem

  • The connection gate hid the diagnostic. projectError rewrote every ForegroundRouteClientError into ProjectClientError('Workbench request failed with HTTP <status>.', code), and the gate rendered .message alone. In the documented no-flag contributor loop (Rsbuild UI on http://localhost:3000, foreground on http://127.0.0.1:3100, no --workbench-dev-origin) that read Workbench request failed with HTTP 200. — no AB8003, no reason, and a 200 presented as the failure when the foreground had answered fine and the client refused the bootstrap body. Recovery attempts that kept failing were swallowed, so restarting the foreground without the flag while the page was open left the gate on Foreground project event stream disconnected. forever.
  • A racy assertion in packages/workbench/tests/mcp-app-preview-browser.test.ts. fixture.bootstrapRequests was a plain array pushed from the fixture server's handler and asserted synchronously right after the in-page create event. The iframe fetches /runtime-bootstrap after that event, so the second-request assertion raced the network (flaked once under contention, passed alone).

Change

Gate, topbar, and the Overview rebuild alert show <code> — <message>, plus (HTTP <status>) only for a failed foreground response.

  • packages/workbench/src/project-client.ts: ProjectClientError keeps the underlying diagnostic's message and code and gains status, taken from ForegroundRouteClientError.responseStatus — set only by fromResponse, i.e. when the foreground actually answered 4xx/5xx; failures the client constructs itself (refused 200 bootstrap body, superseded/invalidated session with a nominal 401) carry none. New projectFailureText(reason, fallback) formats the one line and omits the parts a failure lacks; non-ProjectClientError reasons keep errorMessage(reason, fallback) (the existing client-helpers.ts helper — nothing copied). #refreshRecoveredSource reports through projectError too. #recover now reports a failed attempt through onError when its line differs from the last report (retries run every 250 ms; identical refusals are not re-reported), so the gate moves from the disconnect line to the refusal that keeps recovery from completing.
  • packages/workbench/src/main.tsx: the three setConnectionError sites (bootstrap rejection, connect error listener, beforeInstanceChange close failure) and the Overview rebuild() alert use projectFailureText; the topbar's Foreground server unavailable: … status is the same line. The rebuild alert therefore reads e.g. AB8003 — Request origin is not this foreground server. (HTTP 403) instead of Workbench request failed with HTTP 403..
  • ConnectionGate moves out of main.tsx into packages/workbench/src/workbench-screen.tsx beside Topbar; main.tsx imports it and the inline copy is gone in the same commit (extract and rewire, one change). Markup is unchanged (overview.e2e and packed-release.e2e locate the gate by heading).
  • packages/workbench/src/mcp/mcp-route-client.ts: the client-side AB8003 refusal (page origin is neither body.origin nor in devOrigins) names both origins and the fix instead of Foreground session bootstrap origin does not match this browser.; ForegroundRouteClientError gains responseStatus (see above). No new error class; diagnostic() is untouched.

What the no-flag loop shows on the gate now, exactly:

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.

No (HTTP 200): the foreground returned 200 with a well-formed body naming its own origin, and the UI refused it — a 200 was not the failure, and printing it was the misleading part of the old line. A refusal the foreground itself answers with, e.g. 403 {"diagnostic":{"code":"AB8003","message":"Request origin is not this foreground server."}}, renders AB8003 — Request origin is not this foreground server. (HTTP 403); a status-route 500 renders AB8007 — Request could not be completed. (HTTP 500); a ProjectClientError without a code (Foreground project event stream disconnected.) renders unchanged; a client-invalidated session renders AB8019 — Foreground authentication was invalidated. with no status.

Deterministic runtime-owner assertion.

  • packages/workbench/tests/support/http.ts gains requestRecorder(){ arrived(n), paths, record(path) }. arrived(n) resolves inside the handler that records the nth request, or on the microtask queue when n have already arrived; pending waiters settle FIFO; no timers. The four "a request arrived" assertions in mcp-app-preview-browser.test.ts (two per runtime case: after the first factory and after create:runtime-binding-b) become await within(fixture.bootstrapRequests.arrived(n), 5_000 * timeScale); the "no new request" checks stay synchronous reads of .paths. No sleeps, no polling; within only turns a missing request into Timed out after … instead of the 45 s test timeout.
  • It lives in tests/support/http.ts — the fixture HTTP module that already owns closeServer for loopback fixture servers — not in workbench-fixture-config.ts, which is the Rsbuild config factory (createWorkbenchFixtureConfig) and has nothing to do with fixture servers. mcp-app-frame.test.ts has the same array + expect.poll shape and can adopt the recorder in a follow-up.
  • tests/support/browser-origin.ts holds the one withBrowserOrigin stub (was inline in mcp-route-client.test.ts; project-client.test.ts needed it too).

Proof

  • Unit — project-client.test.ts (+6: connect() against a 200 bootstrap for a foreign page origin rejects with code 'AB8003', the exact message, status undefined, the same object on onError, connection.state 'unavailable'; a refused rebuild() carries { code: 'AB8003', status: 403 } and formats as AB8003 — … (HTTP 403); a refused session re-bootstrap during recovered-source refresh reaches onError as a ProjectClientError with code and status; a session the client invalidated mid-bootstrap carries code 'AB8019' and no status; two identical refused recovery attempts produce one report after the disconnect line and none once the next attempt succeeds; a projectFailureText matrix — code+status, code only, no code, plain Error, hostile getter, non-Error, undefined; the 500 case now asserts { code: 'AB8007', message: 'Request could not be completed.', status: 500 }), workbench-screen.test.ts (+3: ConnectionGate unavailable + 403 → <p role="alert">AB8003 — Request origin is not this foreground server. (HTTP 403)</p>; unavailable + no-flag 200 → code prefix and no HTTP; connecting → no alert), mcp-route-client.test.ts (both AB8003 rejections assert the full message for http://localhost:3001/:3000 against http://127.0.0.1:4100). pnpm test:unit: 3730 passed, 0 failed.
  • Browser — contributor-hmr.e2e.test.ts extended with steps 4 and 5. Step 4, live: with the page open and connected, the foreground is restarted on the same port without workbenchDevOrigins (the compiled dev server keeps proxying to it); the event stream drops, recovery re-bootstraps through the proxy, and the test asserts the Foreground connection unavailable heading and getByRole('alert') toHaveText of the exact line above (built from devOrigin and the foreground URL) — i.e. the gate settles on the refusal, not on Foreground project event stream disconnected. (this is the browser-level proof of the recovery-reporting fix; before it the alert never left the disconnect line). Step 5, fresh load (about:blank first — a goto to the URL already shown is a same-document navigation): bootstrap GET /api/project/session is 200 with no Origin request header, body origin = foreground URL and no devOrigins; the same heading and exact alert text, containing AB8003 and --workbench-dev-origin <devOrigin>, not containing HTTP 200 or Workbench request failed; no Bundle dashboard; no page errors; and the server-side half — the same Origin through the proxy is now 403 AB8003. 4/4 consecutive runs green.
  • Flake check — mcp-app-preview-browser.test.ts 10× sequential in the integration config: 10/10 passed (4/4 tests each; 8.8–11.9 s per run, 98 s total). Extra contention: 3 concurrent loops × 3 → 9/9; 2 concurrent loops × 2 pinned to two cores (taskset -c 0-1, CI-like) → 4/4.
  • Gates on the final tree: pnpm typecheck ✓ · pnpm lint ✓ (0/0, 1266 files) · pnpm test:unit ✓ (3730) · integration subset pnpm test:integration:run filtered to mcp-app-preview-browser.test.ts, contributor-hmr.e2e.test.ts, overview.e2e.test.ts ✓ · pnpm docs:site:build ✓ (typecheck, Rspress build, language parity).

Docs

website/docs/{en,zh}/guide/development/workbench.mdx, the one paragraph describing the no-flag failure: Workbench request failed with HTTP 200. is replaced by the exact gate line above, the rule that (HTTP <status>) is appended only when the foreground response itself failed (the 403 example), and the live case — restarting the foreground without the flag while the page is open shows the disconnect line, then the same AB8003 refusal. reference/security.mdx, reference/cli.mdx, and packages/agent-bundle/README.md do not describe the gate's text and are unchanged.

Files

  • packages/workbench/src/{project-client.ts,main.tsx,workbench-screen.tsx,mcp/mcp-route-client.ts}
  • packages/workbench/tests/{project-client,workbench-screen,mcp-route-client,mcp-app-preview-browser,contributor-hmr.e2e}.test.ts, packages/workbench/tests/support/{http,browser-origin}.ts
  • website/docs/{en,zh}/guide/development/workbench.mdx
  • .changeset/workbench-gate-diagnostic-code.md

Changeset

'agent-bundle': patch. packages/workbench is private, but its dist ships inside the published agent-bundle package (dist/workbench), so the gate text is user-visible in the next release — the same reasoning as #579's Workbench-only changeset. The skip-changeset label this PR opened with was removed on the first reviewer's finding.

Self-review

Pass 1 — change-risk-reviewer (inherited model; TraceDecay daemon down, static review).

  • Warning, changeset gap: skip-changeset was wrong because the Workbench dist is published inside agent-bundle (precedent feat(workbench): launch the standalone MCP Inspector and open it in a new tab #579). Fixed.changeset/workbench-gate-diagnostic-code.md, label removed.
  • Note, undeclared wording change on the Overview rebuild error (rebuild() rejections lost Workbench request failed with HTTP <n>. and rendered the bare server message without code/status). Fixed — the rebuild alert is formatted by projectFailureText too (renamed from connectionFailureText since it no longer serves only the gate); unit test on a refused rebuild() pins { code, message, status } and the rendered line.
  • Note, #refreshRecoveredSource wrapping had no distinguishing test. Fixed — test: recovered stream opens with a forgotten session, the status read succeeds, the snapshot re-bootstrap is refused 403 AB8003, and onError receives a ProjectClientError with code and status.

Pass 2 — generalPurpose on gpt-5.6-sol-medium, after the fixes above.

  • Warning, #recover swallowed every failed attempt, so a live foreground restart without the allowlist left the gate on Foreground project event stream disconnected. and never showed AB8003. Fixed#reportRecoveryFailure reports an attempt whose line differs from the last report (deduplicated, no listener flooding at the 250 ms retry cadence); unit test covers two identical refusals → one report, success → no further report; docs en/zh state the live case.
  • Warning, status >= 400 treated client-constructed errors (AB8019 … invalidated, nominal 401) as failed responses, contradicting the docs/changeset claim. FixedForegroundRouteClientError.responseStatus is set only by fromResponse; ProjectClientError.status comes from it, no heuristic; unit test: a session invalidated mid-bootstrap has status undefined and renders without (HTTP …).
  • Verified by the reviewer: projectFailureText outputs for code+status / code only / no code; no remaining Workbench request failed with HTTP producer or connectionFailureText reference; browser-origin.ts has two test importers; requestRecorder cannot strand a waiter and resolves already-satisfied counts; the changeset quotes the mcp-route-client.ts message verbatim; no dead modules, duplicated helpers, sleeps/polling, or en/zh parity gaps.

Pass 3 — generalPurpose on gpt-5.6-sol-medium, on the pass-2 fix commit (db59f5672) only.

  • Warning, docs: "shows the same line" for the live-restart case was ambiguous — the reviewer read it as the 403 (HTTP 403) example, reasoning that a restarted un-allowlisted foreground answers /api/project/session with 403. Reworded in en/zh to "the same status-less AB8003 — Origin … line". The premise is not what happens: the recovery bootstrap is the browser's same-origin GET without Origin, so the foreground answers 200 and the UI refuses the body exactly as on a cold load — now proven in the browser by the new live step 4 of contributor-hmr.e2e.test.ts, which asserts that exact status-less line after a live restart.
  • Verified clean by the reviewer: dedup ordering (connect() rejection then recovery — no double report; disconnect → refusal → both reported; A/B/A transitions each reported); main.tsx listener is last-error-wins; close() interplay; all 14 direct ForegroundRouteClientError constructions are client-side and only the three fromResponse calls set responseStatus; status untouched and no status === 404 consumers; both new unit tests deterministic (the recovery test's two setImmediate ticks cover the fetch/body/status microtasks; 20/20 focused runs); en/zh equivalent; no hygiene issues.

…rministic runtime-owner assertion

The connection gate rendered ProjectClientError.message alone, which projectError
had rewritten to 'Workbench request failed with HTTP <status>.' - so the no-flag
contributor loop showed 'Workbench request failed with HTTP 200.' with no AB8003,
no reason, and a 200 presented as the failure. ProjectClientError now keeps the
diagnostic message and code and carries status only for a 4xx/5xx response;
connectionFailureText renders '<code> - <message> (HTTP <status>)' on the gate
(ConnectionGate, extracted to workbench-screen.tsx) and the topbar. The client-side
AB8003 refusal names the page origin, the foreground origin, and
--workbench-dev-origin.

mcp-app-preview-browser.test.ts asserted the second /runtime-bootstrap request
synchronously after the create event; requestRecorder() in tests/support/http.ts
lets the test await the nth request through a promise the route handler settles.
@ScriptedAlchemy ScriptedAlchemy added the skip-changeset PR changes a publishable package but ships no observable change; changeset not required label Sep 5, 2026
@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ff62f76

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T04:10:36.249677Z 20dd637 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@589
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@589
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@589
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@589

commit: ff62f76

…ver rebuild and recovered-source refusals

- projectFailureText (was connectionFailureText) also formats the Overview
  rebuild alert, so a refused rebuild reads 'AB8003 — … (HTTP 403)' instead
  of the bare server message.
- agent-bundle patch changeset: the Workbench dist ships inside the
  published package (precedent #579), so the gate text change is user-visible.
- Unit tests: rebuild() rejection carries code/message/status; a refused
  session re-bootstrap during recovered-source refresh reaches onError as a
  ProjectClientError with code and status.
@ScriptedAlchemy ScriptedAlchemy removed the skip-changeset PR changes a publishable package but ships no observable change; changeset not required label Sep 5, 2026
ScriptedAlchemy and others added 3 commits September 5, 2026 04:33
…HTTP status only from a failed response

- #recover reports a failed attempt through onError when its line differs
  from the last report, so a foreground restarted without
  --workbench-dev-origin moves the gate from 'Foreground project event
  stream disconnected.' to the AB8003 refusal instead of retrying silently.
- ForegroundRouteClientError.responseStatus records the status of a failed
  foreground response (fromResponse); ProjectClientError.status comes from
  it, so client-constructed 401s (superseded/invalidated session) carry no
  '(HTTP 401)'.
- Tests for both; docs en/zh mention the live-restart case.
…he AB8003 line; docs wording

contributor-hmr.e2e step 4 keeps the page open while the foreground restarts
without --workbench-dev-origin and asserts the alert becomes the exact
status-less AB8003 line (the recovery bootstrap is an Origin-less GET the
foreground answers 200 and the UI refuses); step 5 is the fresh load as
before, via about:blank because a goto to the shown URL is same-document.
Docs en/zh name that line explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant