Skip to content

Feat/webui nextjs framework - #18

Merged
weekbin merged 41 commits into
mainfrom
feat/webui-nextjs-framework
Sep 24, 2026
Merged

weekbin merged 41 commits into
mainfrom
feat/webui-nextjs-framework

Conversation

@weekbin

@weekbin weekbin commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Change

Why

packages/webui shipped a frontend that was no longer served and ran its HTTP server from source.
Both were dead ends, and both had been worked around instead of fixed.

The server could not be shipped as source. Running it from source means the published archive
has to resolve its imports at runtime, and it cannot: every @mavis/* package is private: true
with no build output (its exports point at ./dist/*.js, which only the esbuild plugin rewrites
at build time). So "declare the dependency and import it" does not work in any published form. And a
bare specifier that reached the server without being listed in cliExternalModules produced a
runtime that failed on first import — which is how hono went missing from the archive.

The frontend was unreachable code. public/app/**, public/styles/** and public/index.html
were not what the served frontend rendered; the served frontend is a Next.js static export. They
stayed alive only because static.js kept a public/ fallback for them.

Several other mechanisms had the same shape — a workaround that outlived the limitation it was
written for, with a comment still asserting the limitation:

  • the ACP wrapper hard-refused six methods before the call left the process;
  • session/cancel was refused and sent as a request, so the graceful cancel path never ran and
    every stop SIGKILLed the whole engine;
  • the model catalogue was scraped out of mcode's build output;
  • the permission mode was local-only.

The through-line is that the webui was guessing at, or routing around, an engine it can simply ask.
This PR removes the guessing.

What

The server is one bundle, and the artifact is gated — build(webui): bundle the server and gate the artifact. scripts/build.mjs bundles server/bootstrap.js into dist/webui/server.js, sharing
the workspace-source plugin with the CLI build. scripts/check-webui-bundle.mjs is a new
verification gate so the bundle's bare specifiers, cliExternalModules and the release manifest
cannot drift apart again. The entry stays server.js at the webui root, because packages/tui's
launcher resolves exactly that path.

The frontend is the Next.js desktop stack (feat(webui): rebuild the frontend on the Next.js desktop stack), and the legacy tree plus the static.js fallback that kept it alive are gone
(refactor(webui): serve one static root and delete the unreachable legacy UI), so the server
serves a single static root.

The API moved onto Hono (refactor(webui): move the API onto Hono). OWNED_ROUTES in
server/app.js is the migration ledger, asserted by test/server/app-hono.test.js. router.js
keeps static/HTML handling, the two SSE channels (the buffered response capture cannot model a
streaming write), and /api/health + /api/settings — retained deliberately so the
origin/CORS/rate-limit gate tests exercise the legacy path and get a 200 instead of the 404 path;
the Hono app owns both endpoints for real consumers.

The server libraries are split by responsibility (refactor(webui): split the server libraries by responsibility): the four-layer better-sqlite3 resolution chain, the session-delete SQL, the
streaming chat-line writer and the context percentage each became their own module, and layout.js
is the one place the bundle resolves the webui root from the entry path.

The test tree is organised by subject (test(webui): reorganise the test tree by subject):
checks/ folds into test/, and files are named for the module or route under test
(test/lib/<module>.check.mjs, test/routes/<route>.check.mjs) instead of a shared prefix that only
recorded where a file came from.

Documentation and comments describe the current implementation (docs(webui): align the documentation and comments with the implementation, docs(webui): correct the comments and notes the ACP change falsified). Module inventories named deleted files, API.md documented fallbacks the
router does not implement, CAPABILITIES.md pointed at deleted frontend symbols, and comments
narrated how a file had evolved instead of stating what a reader cannot recover from the code. Each
claim is restated against the code. Comments now keep the external contracts — paths, environment
variables, protocol methods, ordering/idempotency invariants — and drop the rest. The 41 translation
keys no component reads are removed from both dictionaries (a key is only reachable through t(),
and MessageKey is derived from the English dictionary, so nothing flags an unused one).

The behaviour changes this PR also carries

The commits above are organised by the files they touch, so the behavioural fixes sit inside them
rather than in commits of their own. Listed here so they are not missed in review.

The ACP wrapper now reaches what the engine implements (in refactor(webui): split the server libraries + refactor(webui): move the API onto Hono). Six methods were refused before the call
left the process. Five have engine handlers, and the two with real callers also sent the wrong wire
fields:

method engine expects we sent
session/set_mode modeId, validated against the session's availableModes mode
session/set_config_option configId key

session/activate was refused with a comment claiming it is not a public JSON-RPC method;
extensions.ts registers it. session/fork and session/resume have no route at all, so the entry
only misreported the engine. session/delete is the one method the engine registers but never
implements, and nothing called it either — deletes go through SQL on the local_runtime_* tables.
With the refusal set gone, the refusal branch in callRpc had no reachable caller and was removed;
MCODE_ACP_CAPABILITIES now reports what the wrapper can actually reach.

Cancel goes through the engine instead of SIGKILLing it (same two commits). The engine registers
session/cancel with app.onNotification and aborts the active prompt's AbortController. The
wrapper both refused it and sent it as a request, which has no handler — so the graceful path never
executed and /api/stop always fell through to child.kill(), taking every other session in that
process down with it. It is now sent as a notification; SIGKILL is kept only for an unreachable
client and for a child that outlives the grace window.

The model catalogue and the permission mode come from the engine (in the same two commits, plus
feat(webui): rebuild the frontend on the Next.js desktop stack for the frontend re-fetch). The
catalogue was scraped from mcode's dist/cli.js and its chunks/*.js, and /api/models reported
source: "mcode-cli-bundle". The engine already publishes it as a model config option (values from
runtime.listModels) and returns it from session/new and session/load, so the route reads that
instead. /api/permissions now calls set_config_option{configId:'permissionMode'} rather than
writing local state and answering mcodeSynced: false. The config_option_update handler expected
{key, value}, while the engine sends the whole configOptions array — so it never matched and
cs.permissions could not follow a change made by another client; it now replaces the array and
re-derives the label.

Session rename is carried onto the current stack (in the frontend, Hono and server-library
commits). The rename work landed on main against the legacy frontend this branch deletes, so it is
re-implemented on the current one: the Hono route and its ledger entry, the inline rename affordance
(Enter/blur commits, Escape discards; editing swaps the row's element because a <button> may not
contain an <input>), and a titleCustom overlay in buildTree. The overlay is not cosmetic — the
sidebar reads titles from mcode's runtime db, where a user title does not exist, so without it a
rename would never reach the UI. The rename handler also drops the tree cache, or the old title
survives for its TTL.

Also merged from main during the rebase onto 0.5.2: the session-rename CRUD route, the workspace
gate on POST /api/sessions, draft↔mcode binding at session/new, and three SSE/ACP memory fixes.

Second round: the reviewer's findings, engine-sourced quota, and the defects behind the reports

The plan quota no longer comes from a key the web server stored. The usage popover read its
5-hour and weekly figures by calling MiniMax's quota endpoint with a Subscription Key the operator
pasted into the web settings — a plaintext secret in settings.json, kept in order to repeat a call
the engine already makes. It now maps the mcode/account/status ACP projection
(server/lib/usage.js), and handleUsage no longer answers {ok:true} before the figures arrive —
the popover reads the response body, so a successful fetch still rendered "unavailable". What left
with the key: the quotaEnabled / tokenPlanApiKey settings, MCODE_WEBUI_TOKEN_PLAN_KEY and its
_FILE variant, and the snapshot fields that published them. buildPersistBody() is an explicit
whitelist, so the first start after this rewrites settings.json without the retired fields rather
than leaving a credential on disk for a feature that no longer reads it. Two smaller fixes rode along:
the old parser zeroed the session* counters on every /api/usage call (they belong to the chat
flow), and the weekly reset time is recorded for the forecast instead of a hardcoded null.

The usage popover had never shown anything at all. Live verification found getQuota() calling
GET /api/usage, which no route registers — the call 404'd, so the popover rendered its failure line
whatever the engine reported. Both /api/usage and /api/usage-trigger are POST because the route
fetches from the engine and appends to the forecast history. It also drew a single row labelled
"Quota" from the 5-hour pair while weeklyRemaining / weeklyResetAt went unused, so the weekly
window was fetched and discarded; there is one row per window now, with the percentage labelled
"Used" because a bare "7%" beside "Quota" read as 7% left rather than 7% consumed.

Reviewer findings (@modacker):

  • ownsRequest built a second Hono app per request just to decide dispatch;
    createHonoListener() returns { app, listener } and the bootstrap threads one app through both.
  • POST /api/protocol/cancel answered an undeliverable notification with fallback: "hard_kill"
    while nothing on that path kills anything; the payload now names POST /api/stop, which carries the
    cascade.
  • routes/chat.js dropped a dynamic import of a module already in the cache.
  • The 32 hardcoded local_runtime_* tables in mcode-session-delete.js are left as they are — the
    reviewer marked that non-blocking and the fix belongs on the engine side (see Known gaps).

Transcript rendered raw protocol text. A tool_call_update whose tool_call never arrived
appended [status] / @ path lines with no → name header above them, and decodeTranscript
cannot attribute an indented line to a tool without one — so a run of them became a block labelled
系统. The update writes its own header now, and the decoder refuses to fabricate a system row from a
line opening with a protocol glyph. config_option_update also propagates a model change, which its
own comment had always claimed it did.

Sidebar and layout. The Local/Cloud segmented control could only ever be half real (mcode exposes
no cloud sessions), and above it sat a folder glyph whose only effect was to open the workspace panel
the toolbar already opens — setWorkspace has no caller at all, so it never switched anything. Both
are gone. The drawer no longer opens on 工作区 by itself. The AI-content disclaimer moved into the
conversation column, because as a sibling of the drawer row its text-center centred it across the
drawer too. The account menu's submenus are usable for the first time: the panel is a sibling of its
trigger, so the trigger's mouseleave closed it the instant the pointer set off toward the panel, and
right-[calc(100%-8px)] resolved to a position 8px from the trigger's left edge, laying the panel
out past the left edge of the window. Their rows also pointed at example.com and
support@example.com — links that look live and go nowhere — and are disabled rows now.

Three display corrections, measured against the engine's own database. sessionCount counted
roots + children, and every child is a session_kind='task' sub-agent: the CTAS project's pill read
371 for 175 conversations. mcodeVersion in /api/health and /api/settings was a pinned "0.1.2"
— both read the engine's agentInfo now (0.5.2 here). And the composer's model chip printed the
engine's encoded value while the dropdown a few pixels away printed the display name, so one model
had two names.

Third round: quota polling, and a model name that was never the engine's

The usage popover is polled now. It fetched only when hovered, so the figure
was as old as the last visit and the refresh button was the only way to get a
current one. startQuotaPolling (in the app store) reads it on load and every
two minutes while the page is open, and a hidden tab catches up on
visibilitychange.

Polling is a read, not a measurement. Every POST /api/usage used to append a
sample to the forecast history; one sample every two minutes would grow that file
without bound for a forecast that only reads within the weekly window. record
now decides (lib/usage.js, defaulting to true so a caller that says nothing is
unchanged), the poll sends record: false, and only the refresh button asks for
a sample.

That button could not have worked. The usage popover is portalled to
document.body, and the account menu's outside-click guard knew only about the
contact/learn-more submenu. A mousedown on the button therefore closed the menu
and unmounted the button before the click dispatched, so its onClick never ran.
The guard covers both flyouts now — the same class of bug as the submenu hover
fixed in the second round.

The composer named a model the engine had not named. GET /api/models
answers with the engine's model config option, whose value is the engine's
encoding — m:<provider>:<model>:v:<variant>. With no session there is no option,
and the route fell back to webui's own DEFAULT_MODEL, minimax_api/MiniMax-M3:
a different encoding and a different fact, rendered as the active model while
the session was running
m:custom_provider%3Aopencode-go:deepseek-v4.1-flash:v:thinking. It also wrote
that value into cs.model.name, which is what a later prompt would carry. The
route answers null now, writes nothing back, and the composer shows its neutral
label until a catalogue arrives.

A model switch does not add a session. Reported as "switching the model adds
sessions to the sidebar"; measured against a live engine (load a session, change
its model through session/set_config_option, revert) the session row count is
unchanged and no row is added — the runtime selects the model in place. Recorded
here so the next report can start from that.

Validation

Run on this PR's head, rebased onto main at 85f74a6 (0.5.2). pnpm verify — the same gates as CI,
in order — exits 0: all 16 gates pass.

check result
pnpm build pass — "Built MiniMax Code 0.5.2 from 6253 source files"
pnpm typecheck / pnpm webui:typecheck pass
pnpm test:webui 1273 tests, 1271 pass, 2 skipped, 0 fail, 0 cancelled
pnpm test:webapp 183/183 pass
pnpm check:source pass — 4541 reviewed source paths
pnpm check:tsconfig pass — 126 package exports
pnpm check:standalone pass
pnpm check:webui-bundle pass — dist/webui/server.js, 434537 B, 56.6× source
pnpm test:release-tools 52 tests, 51 pass, 1 skipped
pnpm test:capabilities / test:status-contract / test:smoke / test:byok / test:artifact / test:policy pass

Live acceptance, this round. Earlier rounds were offline only; these changes were driven against a
running engine on this host (pnpm mcode-web, dist/ built from this head) and a real browser:

hop observed
engine → GET /api/account tokenPlanQuotaState: available, fiveHour.remainingPercent 93, weekly.remainingPercent 85, both reset instants, identity.name MiniMax802592, tokenPlan.tier Ultra Plan — and no credential value anywhere in the payload
webui → POST /api/usage {ok: true, source: "acp", remaining: 93, weeklyRemaining: 85, resetAt, weeklyResetAt}
browser → usage popover two rows: "5-hour limit — Used 7% — Resets 9/23/2026 8:00 PM" and "Weekly limit — Used 15% — Resets 9/28/2026 12:00 AM", each with its own gauge; popover inside the viewport; 0 console errors
browser → sidebar no role="tablist" and no workspace chip; no drawer auto-opened on the home screen; the disclaimer's box ends at the drawer's left edge (240→1112 of a 1400px window) and centres at 676, i.e. in the conversation column
browser → account menu Contact us submenu opens, stays open when the pointer moves onto the panel, lays out to the right inside the viewport, both rows disabled (aria-disabled)
browser → quota polling POST /api/usage {"record":false} fired on load with no interaction; the forecast history file stayed absent through repeated polls; clicking refresh sent {"record":true} and appended exactly one sample
GET /api/models, no session {models: [], current: null, reason: "no_session_config"}; the composer chip renders its neutral Model label, where it previously claimed MiniMax-M3
engine, model switch loaded a dormant session, changed model via session/set_config_option, reverted: total session rows 585 → 585, 0 added
sessionCount CTAS pill reads 175 against the engine's database: 175 conversations, 196 sub-agent rows no longer added — the pre-fix value was 371

NOT RUN / not covered:

  • Windows profile not run — this host is Linux. The bundle gate is intentionally not marked
    windows: true, since it validates an artifact only the profile that runs build produces.
  • session/cancel was not exercised against a live prompt. The route now sends the engine's
    session/cancel notification rather than rejecting it, and the offline tests cover the wire shape;
    an interrupted live turn was not run.
  • GET /api/usage is still unregistered. The client now uses POST; a GET returns 404 by design.
  • test:capabilities failed on the last run of this host (2 of 4484), on tests this change cannot
    reach.
    They are packages/tui/test/unit/tui/theme/runtime.test.ts's two custom-theme reload cases,
    which wait for an fs.watch callback; both fail in isolation, on a tree where git diff e3f903b..HEAD -- packages/tui is empty, and the file imports nothing but
    packages/tui/src/tui/theme/*. The host has 294 open inotify instances against a per-user limit of
    128 with several browsers and editors running, which is why the watcher never fires. The same suite
    passed earlier in this session on this head's ancestry.

Performance: NOT RUN. perf:full is not applicable to this change.

Publication and contribution checks

  • I have permission to contribute these changes under the existing licenses applicable to the changed files/packages; imported material and its provenance are identified and existing notices are preserved.
  • No credentials, account data, real user content, internal source history or private review material is included.
  • Added/removed source files were reviewed before regenerating release/public-source.json; new tests are declared in test/vitest-suites.json where applicable.
  • Shared English/Chinese documentation and capability/verification records are updated where applicable. Mock/offline results are not described as live-service acceptance.

Notes:

  • release/public-source.json was regenerated with node scripts/source-inventory.mjs --write
    (4541 paths). The three new scripts/ files and the added frontend files were reviewed first,
    since recording a file is not the same as deciding it may be published.
  • New webui tests are declared in the webui package's own test script, not in
    test/vitest-suites.json — that file lists the repository-level Vitest gates, which this change
    does not add to.
  • The zh-CN documents were updated alongside their English counterparts throughout.
  • release/public-source.json gained exactly one path this round,
    packages/webui/server/routes/account.js — a route file added when the account card started
    reading the engine, and missed from the inventory at that time. Reviewed, then recorded; the
    file holds no credential handling.

Maintainer handoff

Publication scope or license changes: none. No dependency was removed. @mavis/shared became a
declared dependency of packages/webui (it was already imported, just undeclared).

Shared-source port: the changes are confined to packages/webui/**, docs/webui*.md and three
first-party scripts/ files. No third_party/ vendored source is touched and no upstream commit was
cherry-picked.

Reviewer follow-up

@modacker's review is answered in the commits above: the per-request Hono rebuild, the cancel payload
that promised a kill it never performed, and the dynamic import of a loaded module are all fixed, each
with a test that fails before the change. The 32 hand-written local_runtime_* tables are acknowledged
below as a known gap and left alone, as the review suggested.

Known gaps, deliberately not fixed here

  • session/delete still goes through SQL. The engine registers the method in its protocol layer
    but implements no handler, so deletes write ~32 local_runtime_* tables by hand
    (server/lib/mcode-session-delete.js; sqlite-resolver.js records the probe that established
    this). Those tables are schema knowledge the webui does not own and cannot keep correct. The engine
    already has the real delete path
    (local-runtime-v2/.../sessions/lifecycle/deletion-service.ts) and already exposes extension
    methods of that shape (mcode/session/queue/delete), so the fix belongs on the engine side as an
    mcode/session/delete; the webui can then drop the SQL. Not done here because it needs a change in
    a package this distribution vendors from upstream.
  • Session history is still read from the runtime db in two of three places. The engine replays a
    session's full history as session/update notifications on session/load/session/resume, and
    the webui currently drops them (nothing subscribes during the load — acp.mjs's listener window is
    scoped to a prompt, and user_message_chunk is not in its mapping). Two readers can move onto that
    replay: the switch-path backfill and the export enrichment. The switch path cannot do so as-is,
    because it never loads the session in the engine — using the replay there means making a sidebar
    click attach the session, which is a behaviour change worth reviewing on its own. The third reader
    cannot move at all: lib/transcript-sync.js polls the db so a browser tab tracks a session being
    driven by another client, whose writes are not pushed to this connection.
  • test/integration/upload-limits.test.js is flaky independent of this change. An A/B probe on
    this host — 5 runs with the change set, 5 without — put the same failure rate on both (4/5 each,
    failing on client wrote N of 8388718 or a premature EPIPE). Its assertion depends on how much
    the client can push into the kernel socket buffers before the server's 413 closes the connection,
    which is not a property of this diff. Two ad-hoc test-isolation defects found while probing are
    fixed: router-boot.test.js never redirected the usage-history path, so its "fresh server"
    assertion depended on the operator's real ~/.mcode-webui/usage-history.ndjson, and
    router-readonly.test.js wrote the operator's real settings.json and events.ndjson.
  • type TranscriptLine = string is a locally invented line grammar. Replacing it with the
    engine's own session/update payloads is the same change as the replay work above, so it is left
    with it rather than designed twice.

@weekbin
weekbin force-pushed the feat/webui-nextjs-framework branch 3 times, most recently from ad2c268 to ab09dcc Compare September 23, 2026 06:52
@weekbin
weekbin marked this pull request as ready for review September 23, 2026 07:11

@modacker modacker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This is a net-positive cleanup, not a "屎山". The Hono migration uses createResponseCapture to reuse (req, res, ctx) handlers without rewriting them, the 5-gate chain is shared between Hono and the legacy router, and the cancel-as-notification fix is correct end-to-end. Three concrete findings below are worth fixing before merge.


Findings (actionable)

1 · ownsRequest rebuilds the entire Hono app on every request — performance regression

packages/webui/server/bootstrap.js:101 calls ownsRequest(method, pathname) without a third argument, and app.js:154-158 falls back to createHonoApp():

export function ownsRequest(method, pathname, app = null) {
  const a = app ?? createHonoApp();   // re-registers ~47 routes per request
  ...
}

createHonoListener() (line 501-503) already built a Hono app and closed it over. A Hono-owned request therefore triggers two full Hono constructions: one for the dispatch decision, one for the actual serve. The sidebar's ?refresh=1 and per-state-push polling make this non-trivial.

Fix: hoist the app to module scope (const app = createHonoApp()) and pass it into both createHonoListener and ownsRequest. Or have createHonoListener return { app, listener }.

2 · POST /api/protocol/cancel returns fallback: "hard_kill" but never hard-kills

packages/webui/server/routes/protocol.js:130-141:

const r = await cancelSession(sessionId);
if (!r.ok) {
  return respond(res, 200, {
    ok: true,
    cancelled: false,
    warning: r.error,
    code: r.code,
    fallback: "hard_kill",   // nothing actually kills here
  });
}

Only the notification was attempted. The actual gentle-then-SIGKILL cascade with the 2s grace window lives in chat.js#handleStop at /api/stop (packages/webui/server/routes/chat.js:243-289). A client UI that calls /api/protocol/cancel for "stop" reads fallback: "hard_kill" and may stop polling while the child keeps running.

Fix: rename the field to something honest (note: "notification_failed_run_api_stop_for_kill"), or chain this endpoint into the same SIGKILL path.

3 · No integration test against a real engine for the ACP changes

The headline behaviour fixes — session/cancel as notification instead of request, set_mode/set_config_option wire-field renames (modeId/configId not mode/key), the fork/resume/activate routes — are all verified against mocks. If packages/tui/src/acp/agent.ts ever registers session/cancel as a request again, this PR's tests won't catch it.

The PR description itself flags: "No live-service acceptance. Every check above is offline. packages/webui/test/** mocks the ACP client, so the ACP changes are covered at the protocol boundary, not against a running engine."

Fix: a smoke harness spawning mcode acp --mock-stub (or whatever test stub exists) and asserting the wire shape end-to-end.


Known / acknowledged gap (not blocking)

4 · mcode-session-delete.js hardcodes 32 local_runtime_* tables

packages/webui/server/lib/mcode-session-delete.js:24-62. The PR description flags this explicitly as "schema knowledge the webui does not own and cannot keep correct." The fix belongs on the engine side as mcode/session/delete; out of scope here.

Two consequences worth tracking:

  • When mcode adds a new local_runtime_* table, the per-table try/catch classifies it as "absent" (no PRAGMA-confirmed table_info), so new message/state types degrade silently.
  • _isConfirmedUnsupportedSchema (line 102-117) fails-closed after mcode ships a keyless table, but the silent orphan window between mcode release and webui release is unavoidable.

Suggest a CI sanity test that counts local_runtime_* tables in ~/.minimax/v2/sqlite and asserts the list covers them.


Observations (lower-impact)

  • chat.js:254 dynamic await import("../lib/mcode-rpc.js") is misleading — mcode-rpc.js is already transitively loaded at chat.js boot via mcode-acp.js:17 (import { mcodePermissionToWebui } from "./mcode-rpc.js"). Static-import for consistency, or add a one-line comment.

  • router.js:128-147 keeps /api/health and /api/settings only for the OY-3 gate tests — both routes are owned by Hono (app.js:78, app.js:119) in production. Legacy copies are dead code. Gate behind NODE_ENV !== 'production' or remove and target the Hono layer (it runs the same gates).

  • matchHonoPath in app.js:177-198 is hand-rolled: the Limited to the patterns we actually use caveat is fine for :name-only, but a Hono router upgrade with * wildcards or optional segments would silently mis-route. Either pin a Hono router version or extend the helper.

  • acp.mjs prompt() removed result.events (OOM hardening, line 228). A future session_update kind that needs events for finalize would silently lose them; the comment doesn't flag it as a deliberate gap.

  • state-bus.js at 798 lines with 12+ settings.js imports — PR claims "split by responsibility" but state-bus owns per-cid state, SSE channel, alerts re-export, settings aggregation, getCidFromReq. A getRuntimeSettings() facade would let state-bus drop those imports.


What's right

  • createResponseCapture + invokeHandler correctly avoid Promise<Promise<Response>>; the comment is accurate.
  • gates.js factoring with Origin/CSRF (gate 1b) without the local-request exemption is the actual CSRF fix.
  • scripts/check-webui-bundle.mjs is small but high-leverage — bare-specifier × cliExternalModules drift is exactly how hono shipped missing. 3× size floor + Hono marker + comment-aware regex is the right trio.
  • Cancel-as-notification (mcode-rpc.js:97-99) and the chat.js:243-289 cascade ordering (notify → kill → 2 s grace) are correct. The resetThinkingClaim escape hatch for wasRunning=false zombie-claim case is the right defensive coding.

weekbin added a commit that referenced this pull request Sep 23, 2026
From @stevenjj33's `fix/webui-windows-validation`, which validated #18 on
Windows and fixed what that surfaced:

- better-sqlite3 resolution probes the Windows installer layout (the release
  directory named by the launcher's sibling `current` file, with the same
  charset guard the launcher enforces) and the pnpm workspace root.
- `test/lib/config.test.js`, `test/routes/fs-containment.test.js` and
  `test/integration/router-boot.test.js` made cross-platform.
- `/api/health` and `/api/settings` report the engine's own version.

Three conflicts, resolved:

- `server/lib/settings.js` and `server/routes/health.js` — their version
  commit fixes the same two `"0.1.2"` constants this branch already fixed
  ("report the engine's own version, not a pinned constant"), reading the same
  `agentInfo` from the same ACP handshake. Kept this branch's implementation;
  the only net change theirs made to these files was a duplicate import of
  `getMcodeServerInfo`, which is dropped. Both files are byte-identical to the
  pre-merge branch.
- `test/integration/router-boot.test.js` — kept BOTH environment additions:
  theirs points `MCODE_CMD` at a nonexistent path so the suite never spawns the
  engine, this branch's points the usage history at the temp dir so the
  forecast assertion cannot read the operator's own. They isolate different
  things and both are wanted.

Verified after the merge: the three merged suites 53/53 pass; the full webui
suite is 1273 tests, 1271 pass, 2 skipped, 0 fail.
@weekbin
weekbin force-pushed the feat/webui-nextjs-framework branch from 0524196 to 294b2b2 Compare September 24, 2026 07:43
The webui server runs from source, so shipping it shipped a module graph the release
archive could not resolve: `@mavis/*` are private with no build output, and a bare
specifier that crept in without being listed in `cliExternalModules` produced a
runtime that failed on first import, which is how `hono` previously went missing.

scripts/build.mjs now bundles `server/bootstrap.js` into `dist/webui/server.js`,
sharing the workspace-source plugin with the CLI build, and
scripts/check-webui-bundle.mjs is a gate so the bundle, the externals list and the
release manifest cannot drift apart.
…d output

`@mavis/shared` is imported for the data-directory contract, so it is a declared
dependency rather than an undeclared one, and the lockfile is refreshed so
`--frozen-lockfile` installs.

.gitignore now explains each build artifact it excludes: the source inventory scans
the working tree, so an un-ignored artifact would be published as source.
The vanilla-JS frontend is replaced by a Next.js static export that reproduces the
desktop client's layout and design tokens, so markup and class strings can be
checked against the client rather than invented.
…acy UI

`public/app/**` and `public/styles/**` became unreachable once the Next export was
the served frontend, and `static.js` carried a `public/` fallback for them. Both
are gone; the server serves a single root.
Every non-SSE `/api` route is served by the Hono app. `OWNED_ROUTES` is the ledger
the check in test/server/app-hono.test.js asserts against, and the legacy
dispatcher table is empty.
The four-layer better-sqlite3 resolution chain, the session-delete SQL, the
streaming chat-line writer and the context percentage each moved into their own
module. `layout.js` is the single place the bundle reads `import.meta.url`.
`checks/` is folded into `test/`, and files are named for the module under test
(`test/lib/<module>.check.mjs`, `test/routes/<route>.check.mjs`) instead of a shared
prefix. The paths are declared in the webui's test script rather than hard-coded.
…tion

Documentation and comments described the previous architecture: module inventories
named deleted files, API.md documented fallbacks the router does not implement,
CAPABILITIES.md pointed at deleted frontend symbols, and comments narrated how a
file had evolved instead of stating what a reader cannot recover from the code.

Each claim is restated against the code. The comments keep the external contracts
(paths, environment variables, protocol methods, ordering invariants) and drop the
rest, and the 41 translation keys no component reads are gone from both
dictionaries.
Regenerated with `node scripts/source-inventory.mjs --write` after the paths above
changed.
Reaching `session/cancel`, `set_mode`, `set_config_option` and `activate` made
several statements wrong in the same breath: /api/stop's header said cancel was
unsupported so every stop was a SIGKILL, /api/protocol/capabilities advertised
"no graceful cancel in mcode 0.1.5", and the 501 branches were explained as
"mcode 0.1.5 does not support this". None of those hold now.

The remaining `mcode 0.1.x` labels go too: they narrated which engine release a
fallback was written against rather than what the fallback is for. Two of them
were inside log template strings and the capabilities payload, so those two
strings change with the wording.

`node scripts/source-inventory.mjs` is unaffected (content-only edits).
The card carried six hardcoded values — a CDN avatar, the display name, the plan
tier, the workspace name, the user id and the token-plan flag. Only one of them
had a real source and the card ignored it: the server already publishes the plan
tier from the quota API as `usage.plan`. Everything else was fabricated, which is
worse than an empty field because a plausible id and plan are indistinguishable
from real ones.

`plan`, `workspaceName` and `hasTokenPlan` now come from the server snapshot:
the quota API's tier, the basename of the workspace the session actually runs in,
and whether a Token Plan key is configured. The user id and the avatar have no
source, so they render their empty state — the id row is hidden and the avatar
falls back to the workspace initial — until the account API is wired.

Also removes the "switch to classic" entry. It set `window.location.href` to
/mavis, a surface this branch deletes, and its own comment said so: "webui has no
/mavis surface yet … this stays visible so the 1:1 menu shape is preserved". A
menu row that cannot work is not parity. The icon and both i18n keys go with it.
…ng it

The card had no real source for a display name or a plan tier, so it hardcoded
both. The engine already had them: `getAccountStatus()` merges an
`accountIdentityGetter` (lifecycle.ts) that reads the shared OAuth auth context,
and `/status` already calls it — `formatStatus` simply never printed `identity`.
So this is a missing output, not a missing capability, and no second
authorization is involved: the credential is a per-user file in the data
directory both processes already resolve the same way.

Adds the `mcode/account/status` ACP extension method, following the existing
`mcode/session/queue/*` pattern, and `GET /api/account` to carry it to the client.

The payload is an allow-list projection, not a spread of `TuiAccountStatus`:
a field added to that type later must not reach a browser by default. Nothing in
it reads a credential — no access/refresh token, no subscription key, no provider
API key; `managedTokenPresent` is a boolean, not the token it names. `identity.email`
is omitted deliberately: the UI needs a display name, and carrying an unused PII
field over the wire is a leak waiting for a logging accident. Verified against a
running engine: the response contains field names only, no secret values.

The route is on-demand rather than part of the state snapshot. The snapshot is
broadcast to every SSE subscriber, including over the LAN when `lanBind` is on, so
account data does not belong in it; the response is never logged. A failure is a
soft one, so the card renders its empty state instead of a substitute value.

NOTE: `packages/tui` is vendored from upstream (`docs/source-sync.md`), so this
hunk will be a sync candidate. It is additive — one method registration, one
capability-list entry — with no change to existing methods or responses.
It stacked the workspace name over the plan tier, which reads as one identity
when they are two unrelated things — the workspace is the directory the session
runs in, the tier comes from the account. The id row above it had no source and
was already hidden, and its Upgrade / Manage button is permanently disabled with
an "unsupported" tooltip, so the card's only visible content was the confusing
pair. The account name and plan already show on the footer row that opens the
menu, where they are read from `/api/account`.

Its four translation keys go with it, from both dictionaries, along with the
`realUserId` / `copyUserId` / `hasSubscription` values that only the card read.
The usage popover got its 5h / weekly figures by calling MiniMax's quota
endpoint with a Subscription Key the operator pasted into the web
settings. Two problems: that key sat in plain text in settings.json, and
lib/usage.js's own header already said the credential lives with mcode —
so the web server was keeping a second copy of a secret in order to
repeat a call the engine makes anyway.

Quota now comes from the engine over ACP. `mcode/account/status`
(extensions.ts) already projects the plan tier and each window's
remaining percentage, so POST /api/usage maps that projection instead of
calling out. handleUsage also answered `{ok:true}` *before* the figures
arrived, and the popover reads the response body — so a successful fetch
still rendered "unavailable". The body is now the snapshot itself.

What goes away with the key: the `quotaEnabled` / `tokenPlanApiKey`
settings, MCODE_WEBUI_TOKEN_PLAN_KEY and its _FILE variant, and the
snapshot fields that published them. The settings UI that fed them was
already gone. buildPersistBody() is an explicit whitelist, so the first
start after this change rewrites settings.json without the retired
fields — no plaintext credential left behind for a feature that no
longer reads it.

Two smaller fixes in the same path: the old parser zeroed the session*
counters on every /api/usage call, but those belong to the chat flow
(mcode-acp.js accumulates them per turn), so applying a quota reading no
longer touches them; and the weekly reset time, which the engine does
report, is now recorded for the forecast instead of hardcoded null.

Verified: packages/webui 1256 tests / 0 fail, webapp typecheck clean,
docs-alignment clean. GET /api/account and POST /api/usage were both
checked against a live engine in a later commit's run.
Three points from the pull request review.

`ownsRequest(method, pathname)` built a fresh Hono app — 47 routes — for
every request just to decide whether Hono owns the path, while
`createHonoListener()` had already built one. The sidebar's polling paths
paid for two router tables per request. `createHonoListener()` now returns
`{ app, listener }` and the bootstrap threads that app into `ownsRequest`,
so the decision and the serve walk the same table. The app is deliberately
not hoisted to module scope: building it per call is what keeps the route
tests isolated from one another.

`POST /api/protocol/cancel` answered an undeliverable notification with
`fallback: "hard_kill"`, and nothing on that path kills anything — the
gentle-then-SIGKILL cascade with its two-second grace window lives behind
`POST /api/stop`. A client that trusted the field could stop polling while
the child kept running. The payload now names the endpoint that does carry
the cascade.

`routes/chat.js` reached for `mcode-rpc.js` through a dynamic import it
had no reason to pay for: `chat.js` imports `mcode-acp.js`, which imports
`mcode-rpc.js`, so the module is already in the cache.
Two defects behind one report of raw protocol text showing up as chat.

A `tool_call_update` whose `tool_call` never arrived — webui attached
mid-stream, or the update was the first frame seen for that tool — had no
row to insert its body after, so `mcode-acp.js` appended `  [status]`,
`  @ path` and output lines at the end of the transcript with no `→ name`
header above them. `decodeTranscript` cannot attribute an indented line to
a tool without that header, so its fall-through branch collected the whole
run into a `chat.system` row: the transcript showed a block labelled 系统
whose body was `[in_progress]`, `[completed]` and `@ /path/...` lines
verbatim. The update now writes its own header — the tool name is on the
event — and registers it, so later updates for the same tool land under it.
`decodeTranscript` additionally refuses to fabricate a system row out of a
line that opens with a protocol glyph, which also covers a server that has
not been updated; plain indented continuation prose is untouched.

`config_option_update` claimed in its comment that it learns about a model
change made in another client, while only propagating `permissionMode`. A
model switched in the TUI therefore left the web UI naming the old one, and
`cs.model.name` kept being sent as the `model` for the next prompt. It now
reads the `model` option's `currentValue` — the same field
`routes/model.js#handleGetModels` derives its `current` from, so the two
cannot disagree about which field holds the encoded id — and leaves
`cs.model` alone when the option carries nothing usable.

Both handlers moved out of the streaming callback into exported functions,
so they are testable without a live engine.
…n the column

The Local/Cloud segmented control could only ever be half real: mcode
exposes no cloud sessions, over ACP or otherwise, so 云端 was rendered
disabled as a placeholder. Removed, with its three i18n keys.

Above it sat a folder glyph and a workspace name whose only effect was to
open the workspace panel — the room the toolbar's 工作区 button already
opens. It never switched anything: `webapp/lib/api.ts#setWorkspace` has no
caller at all. Removed rather than left as a second door to one room.

The AI-content disclaimer was a sibling of the row that holds the
transcript *and* the drawer, so its `text-center` centred it across the
drawer too and it read as sitting under the drawer. It is a child of the
conversation column now, which is also what keeps it out of the
transcript's scroll area.

The drawer no longer opens on 工作区 by itself. It starts closed: this
workspace panel is mostly placeholders, so opening it in a session with no
history put empty sections and inert buttons in front of the user before
they asked for anything. The toolbar and the sidebar's nav rows still open
a panel on demand.

Contact us / Learn more: every row pointed at `example.com` or
`support@example.com` — links that look live and go nowhere. They are
disabled rows now, in the same shape as 飞书 and 签到, until there is a real
target to open. The submenu itself was unusable for two further reasons:
the panel is a sibling of its trigger, so the trigger's `mouseleave` closed
it in the same tick the pointer set off toward the panel; and
`right-[calc(100%-8px)]` resolved to a position 8px from the trigger's
*left* edge, which laid the panel out past the left edge of the window. One
hover container now owns the row and its panel, and the flyout opens away
from the sidebar.

CAPABILITIES.md loses two claims that described the removed chip; the
workspace switcher is now marked as not wired in the Next frontend.
`state.model.name` is the engine's encoded selection — the `value` of its
`select` config option — while a catalogue entry carries a separate display
`name`. The chip printed the value, so it read `deepseek-v4.1-flash` while
the dropdown a few pixels away listed `DeepSeek V4.1 Flash`. The chip now
resolves the value through the catalogue and falls back to the stripped
value only when the engine lists no entry for it.

Enter also submitted while an IME was still composing. A candidate window
confirms on the same key, so a Chinese message could be sent half-composed.
Enter during composition is left to the IME now.

A send that hangs was invisible: `sending` stays set until the promise
settles, the send button is replaced by the stop button while a turn runs,
and Enter then returns early without a word — so the text simply stayed in
the box and the composer looked dead. The two send endpoints now carry a
deadline (they answer with an ack before the engine runs, so a reply past
30s means the request is not arriving), and the hint under the box says
正在发送 while a send is in flight.

The usage popover also called `GET /api/usage`, which no route registers —
only POST is — so it 404'd and the popover showed its failure line no
matter what the engine reported. Both endpoints that share `/api/usage` are
POST because the route fetches from the engine and appends to the forecast
history; `getQuota()` now uses POST.
MSG2
The sidebar's per-project pill read roots + children, and every child row is
a `session_kind='task'` sub-agent. Measured against the engine's own
database that made one project's pill read 578 for 321 conversations —
close to double — and the tree renders every group collapsed by default, so
the number sat next to a handful of visible rows. The pill now counts the
sessions a user started. Sub-agent rows keep rendering under their parent;
only the count changes.
`/api/health` and `/api/settings` answered `mcodeVersion: "0.1.2"` — the
version webui was written against, not the one installed, which is 0.5.2
here. `/api/protocol/capabilities` already reads the `agentInfo` from the
engine's ACP `initialize` reply; these two read the same field now and say
`unknown` until a client attaches.
`packages/webui/server/routes/account.js` was added when the account card
started reading the engine, and the inventory was not regenerated with it,
so `pnpm check:source` had been failing since. The file is a plain route
handler with no credential handling; reviewed and recorded.
…te directory

`router-boot.test.js` spawns a server and asserts `no_history` on a "fresh
server", but never redirected the usage-history path — so the assertion
depended on whether the machine running it had ever fetched a quota. It
does now.

`router-readonly.test.js` imports the real settings module, so its
`setReadOnly(true)` rewrote the operator's `~/.mcode-webui/settings.json`,
and each audit event it appended went to the `~/.mcode-webui/events.ndjson`
a running dev server is also writing. That read-modify-rename is not safe
against two writers, which is one of the ways this file went red
intermittently. Both paths now point into a per-run temporary directory.
The engine reports two quota windows — one rolling over 5 hours and one
weekly — and `POST /api/usage` has returned both (`remaining` / `resetAt`
and `weeklyRemaining` / `weeklyResetAt`) since it started reading the
engine over ACP. The popover drew a single row labelled "Quota" from the
5-hour pair, so the weekly figure was fetched and then dropped: the
information was in the payload with nothing on screen for it.

One row per window now, each with its own gauge and reset time. The
percentage is labelled 已用 / Used, because a bare "7%" next to "Quota"
read as 7% left rather than 7% consumed. A window the engine reports no
figure for is dropped instead of being drawn as 0%.
weekbin and others added 17 commits September 24, 2026 22:07
The usage popover fetched only when it was hovered, so the number was as old as
the last visit and the only way to get a current one was the refresh button.
`startQuotaPolling` in the store now reads the quota on load and every two
minutes for as long as the page is open, so the popover has a figure before it
is ever opened. A hidden tab does not poll; it catches up on
`visibilitychange`.

Polling is a read, not a measurement. Every `POST /api/usage` used to append a
sample to the forecast history, and one sample every two minutes would grow that
file without bound for a forecast that only reads within the weekly window.
`record` now decides — in `lib/usage.js`, defaulting to true so a caller that
says nothing keeps the old behaviour — the poll sends `record: false`, and only
the refresh button asks for a sample.

That button could not have worked. The usage popover is portalled to
`document.body`, and the account menu's outside-click guard knew only about the
contact/learn-more submenu; a mousedown on the button therefore closed the menu
and unmounted the button before the click dispatched, so its `onClick` never
ran. Same class as the submenu hover bug. The guard covers both flyouts now.
`GET /api/models` answers with the engine's `model` config option, and `value` is
the engine's encoding — `m:<provider>:<model>:v:<variant>`. Before a session
exists there is no option, and the route fell back to webui's own
`DEFAULT_MODEL`, `minimax_api/MiniMax-M3`. That is a different encoding *and* a
different fact: the composer rendered it as the active model while the session
was running `m:custom_provider%3Aopencode-go:deepseek-v4.1-flash:v:thinking`.
Worse, the route wrote that value back into `cs.model.name`, so it was also what
a later prompt would carry.

`current` is `null` when the engine has not named a model, nothing is written
back, and the composer shows its neutral `composer.model` label until a
catalogue arrives. A catalogue that simply lacks the state's value now shows the
engine's own string rather than a stripped or invented one.

Measured against a live engine while writing the test: loading a session and
changing its model through `session/set_config_option` changes no session row
(the runtime selects in place), so a model switch is not itself what adds
entries to the sidebar.
Both are submenus whose every entry points at a product page or a support mailbox
this distribution does not have. In the last round they became rows of disabled
placeholders, which is honest but also a menu that opens onto nothing; the user
asked for them to go until there is a real target.

Removing them takes the whole mechanism with it: the two submenu components, the
shared flyout, the submenu trigger, the `submenu` state, the portal entry in the
account menu's outside-click guard, and the nine `userMenu.*` keys that had no
other reader. The doc comment above the menu says what was removed and what to
add back, since the reference client does have these rows.
…ter-sqlite3

Two install shapes the candidate chain never reached, both verified on a
Windows host with the 0.5.2 installer:

- The Windows installer puts mcode.cmd at the install ROOT and forwards
  to releases/<version>/, reading the sibling `current` file. Both
  MCODE_CMD-derived candidates (npm-style and flat) are anchored at that
  root, so neither reaches the release dir; resolution failed on every
  machine with an installed engine. Emit the release path derived from
  `current`, validated with the same charset the launcher's findstr
  enforces so a stray file cannot inject a path segment.
- A pnpm source checkout hoists better-sqlite3 to the REPOSITORY root's
  node_modules, one level above the tier-4c "packages/node_modules"
  probe. Append the repo-root candidate.

Measured on the failing host: 6 candidates / 0 hits before, 2 hits
after (the installed release dir and the repo root), and the
"cannot load better-sqlite3" warning is gone at runtime.
/api/health and the settings snapshot answered mcodeVersion: "0.1.2" —
a placeholder inherited from the plugin migration — while the installed
engine is 0.5.2, so every consumer of those endpoints was told a wrong
version. Read agentInfo.version from the ACP handshake via the existing
read-only getMcodeServerInfo() accessor instead: the real version once
a session has been initialized, "unknown" before that. Health still
never spawns the engine to fill the field.
… Windows

Three tests that passed on the Linux host the branch was validated on
and failed on Windows, each for a different portability assumption:

- config.test.js asserted WEBUI_DATA_DIR.startsWith("/") — an absolute
  Windows path starts with a drive letter. Use isAbsolute().
- fs-containment.test.js used /etc as the existing directory outside
  the allowed roots; on Windows the route resolves the request to
  "D:\etc", which does not exist, so the handler failed with ENOENT
  ("cannot resolve") before reaching the containment 403. Pick the
  witness path per platform: SystemRoot on win32, /etc elsewhere.
- router-boot.test.js premised "the routes we hit do not invoke mcode"
  but never neutralized MCODE_CMD. GET /api/state does spawn the ACP
  singleton whenever a resolvable engine exists, and on a host with an
  installed engine (the Windows .cmd launcher chain) that first spawn
  overran the 10s client timeout. Point MCODE_CMD at a nonexistent
  path so the suite is hermetic on any host, which is what its own
  comment already claimed.

Post-fix on Windows: the three files pass, and the full suite stands at
1232 pass / 10 fail, where the remaining 10 are 8 symlink-privilege
EPERM failures (containment.test.mjs needs admin or Developer Mode to
create symlinks) and 2 sse-channel coalescing timing tests that pass in
isolation (16ms throttle window vs the ~15.6ms Windows timer
granularity under full-suite load).
Reported as "you cannot tell whether a task is running, or what state a session
is in".

The marquee for a running session already existed — it is the desktop's own
indicator, a gradient swept across the title — but it was driven by the engine's
`status` column as read through the sidebar's 15s cache, and nothing invalidated
that cache when a turn started or ended. A running session therefore looked idle
for up to fifteen seconds, and a finished one kept shimmering.

Two liveness sources feed it now:

- `running.active` arrives over SSE the moment a turn starts, and the active
  session's row reads it directly, so the marquee is immediate rather than
  cache-late.
- That same transition forces a re-read (`?refresh=1`), so every other row's
  status is re-read when the engine rewrites it instead of waiting the cache out.

The states that mean "this did not finish cleanly" were invisible too: `aborted`
(27 rows on this machine), `interrupted` (22) and `error` (16) all rendered
exactly like `idle`, so a session that died on an error was indistinguishable
from a quiet one. Each carries a status dot whose tooltip names the state — the
mark is never the only carrier of the meaning.

The conversation header answers the same question where the transcript cannot:
its own indicator only exists where the transcript is scrolled to, while the bar
is always on screen. It shows the dot loader, the elapsed time and the token rate
while a turn is in flight, and nothing when idle.
Rebasing onto main surfaced PR #20, which replaced the webui's SSE
channels with a WebSocket transport — on the *previous* frontend. This
branch is the frontend replacement, so main's transport is superseded
and its changes are resolved in favour of this branch.

A plain `-X theirs` rebase was not enough. It resolves content
conflicts, but main's changes to files this branch also touched still
landed where the two did not overlap, and its brand-new files stayed
because no commit of ours deletes them. So:

  - removed main's ws/embed cluster — 8 server modules, 8 test files,
    1 fixture, 3 design drafts. Nothing in this tree imported them;
  - restored `routes/alerts.js`: main had converted it to a REST
    snapshot, while `webapp/lib/alerts.ts` still opens an `EventSource`
    on it. That one was a live break, not dead code;
  - restored `state-bus.js`, `test/lib/state-bus.check.mjs` and
    `test/helpers/_setup.js`, which carried main's `event-bus` wiring;
  - restored `docs/HTTPS-REVERSE-PROXY.md` (+zh-CN), which main rewrote
    around the WebSocket Upgrade handshake;
  - restored `routes/health.js` and five test files where the two
    branches' changes had been interleaved.

`acp-client.js` is the one file kept from main, and it is kept
*surgically*. Main's change there carried two things: a genuine fix —
stopping the singleton child before dropping the reference, so a failed
probe no longer leaks a subprocess whose stdio pipes hold the event loop
open — and a call to `syncActiveCapabilities`, part of the transport this
branch removes. Taking main's file wholesale left that call behind with
no import, because the rebase had already dropped the import from this
branch's side: a `ReferenceError` waiting for the first session probe.
The two `client.stop()` blocks are applied by hand to this branch's
version instead, and nothing else.

That single file is the whole remaining difference from this branch's own
version, which is the check that no content was lost in the rebase.

Rebased history is linear and `main` is now an ancestor, so the PR merges
cleanly.
`upload-limits.test.js` — 8 MiB against a 64 KiB request cap — was the
one test that could still fail `pnpm verify` on an unchanged tree. It
failed three different ways over this session, and only the third is
about the test's budget:

  1. `write EPIPE` — the server closing without draining makes the
     client's next write race the 413. Fixed by letting the response
     event decide instead of the interleaving.
  2. `client wrote 4308992 of 8388718` — an assertion of `< 4 MiB`, a
     proxy for "did not buffer the whole body" that measured how far the
     client's write loop got. Replaced with the property itself,
     `bytesWritten < body.length`.
  3. `no response within 15000ms` — this one.

(3) is not a product race. The server writes the 413 and then drains the
unread remainder specifically so the response is not overtaken by an RST;
there is no `destroy()` on that path. Measured: 5/5 passes in isolation,
about 2 in 3 in a full run. `node --test` runs test *files* in parallel,
and this is the heaviest file in the suite — it spawns a real server and
pushes 8 MiB, then the server drains it. So the 15s bound was measuring
the machine's load, not the server.

Raised to 45s for this test, which keeps the assertion meaningful (a
server that never answers still fails) without turning the test into a
load detector.

Also plumbed the child's stdout/stderr into the timeout error. Without
it a failure reported only "no response within Nms" and the actual
reason — a boot warning, a crash, a slow start — was lost; that is why
the first two failures took a diagnosis each.

Measured after: 7/7 consecutive `pnpm test:webui` runs pass.
@weekbin
weekbin force-pushed the feat/webui-nextjs-framework branch from 294b2b2 to f674e00 Compare September 24, 2026 14:48
`test:windows` failed on the Windows runner in the run for this branch:

    × accepts the Windows checkout on a local NTFS volume
      Error: Test timed out in 5000ms.

This is not a regression from this branch — the test and the script it
exercises are byte-identical to main's, and main's own Source
verification passes. It surfaced here for a different reason: the
previous run on this branch failed `check:source` after 2.3s, so
`pnpm verify` aborted before `test:windows` ever ran. Fixing the source
inventory let the run continue far enough to reach it.

The cause is the runner, not the assertion. The check spawns `fsutil`
twice *synchronously*, and the first spawn of a binary on a
Defender-scanned volume pays the scan — measured 8.4s against vitest's
5s default. main's green run passed inside the default, which is exactly
what makes this read as flaky rather than broken.

A 60s explicit budget keeps what the test asserts — that a real Windows
host accepts this checkout on a local NTFS volume — and stops it from
doubling as a stopwatch on the runner.

Local gates cannot exercise this: `test:windows` skips off win32, so the
only verification is the Windows CI job.
@weekbin
weekbin merged commit 8698ee0 into main Sep 24, 2026
18 checks passed
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.

3 participants