Skip to content

feat(webui): engine facade takes over the session read surface (M3 B0–B3) - #148

Merged
fengzhi09 merged 12 commits into
mainfrom
dev-lhl
Oct 2, 2026
Merged

fengzhi09 merged 12 commits into
mainfrom
dev-lhl

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

内容

M3 引擎门面收编前四批(B0–B3)+ 门禁卫生,75 端点中 22 个改走 engine/ 门面。

门禁

pnpm verify 18 道全 PASS。#17 未换 getSessionUsage(默认 acp 无 host),留 M4。

按 SPEC【原则】「通过即可合入 main」:main 保护已设为 CI 强制(verify 三平台 + performance)无 review 闸,auto-merge 挂起,CI 绿即自动合。

重开自 #147——它因保护规则变更后 GitHub 不重评估存量 PR 而卡在 blocked,check 全绿也无法合。此 PR 在保护变更后创建,会被正确评估。

ARCHITECTURE.md:492 counted five files under server/engine/ while the
directory has shipped six since #143. The missing one is
providers/local-runtime-v2.capabilities.js, the declaration-only module
whose sole import is ../capabilities.js — the split that keeps the v2
host's ~4.7 s TypeScript dependency tree off the boot path. The
local-runtime-v2.js row credited itself with the declaration it only
re-exports, so that credit moves to the file that actually defines it.

The zh-CN mirror takes the same edit in the same commit (equal weight);
check-docs-alignment.mjs resolves the new bare-path citation against the
merged tree.
…ine-abstraction M2)

Baseline: feat/engine-capabilities (M1, PR #143), NOT main — the
server/lib/engine -> server/engine path fix has not landed yet.

- test/lib/engine/capability-snapshot.test.js boots ONE real catalogue
  host on an isolated tmp data dir (MINIMAX_DATA_DIR + every
  MCODE_WEBUI_* path pinned before the provider import) and audits
  every full/partial key of both providers against the reflected
  surfaces: full requires every tracked method (REQUIRED_METHODS,
  derived from the live prototype chains — 91 adapter / 94 CliService
  methods — not copied from the design matrix); partial requires the
  present half to exist, method-named missing items to be genuinely
  absent (under-declaration goes red), and kebab-case sub-capability
  names to have no covering method; none is not method-checked.
- Mutation tests pin the checker itself: flipped level / deleted
  method / grown sub-capability each go red (also verified by hand:
  three file mutations red at exit 1, restored byte-identical).
- Registry-driven static guard: every registered provider declares
  exactly ENGINE_CAPABILITY_KEYS — typo keys cannot pass silently, and
  M4 providers are swept without editing the test.
- Docs: ARCHITECTURE.md/.zh-CN.md M2 section, webui.md/.zh-CN.md
  migration-state entry; tmp prefix registered in the leak gate.
… path

The M1 path move took server/lib/engine to server/engine. This file was
written against the old one and rebase carried the code forward without
carrying the import, so the suite failed on MODULE_NOT_FOUND and said
nothing about the capabilities it was meant to check.
…ther

Three leaks, all from state that lived outside the component that
should have owned it.

page.tsx read localStorage during render, so the pre-rendered HTML and
the first client frame could not agree — a skeleton screen was hiding it,
which is exactly the kind of cover that disappears the moment someone
edits the shell. The first frame now uses defaults and one mount effect
restores; the three write-back mirrors are gated so a default never
overwrites a stored value. Scroll position is read by the same sessionKey
effect Chat already had, which reads the same key.

The draft store was a module-level bucket, so a draft, a failure banner
and a model chip all followed you across sessions — type into one
conversation, switch, and your words are in the other. The store is
keyed by session now. Isolation is not discarding: switching back finds
the draft still there. Clearing was the alternative and it destroys an
unread banner every time you return to a conversation.

#126 left the accepted-but-unconfirmed banner without anything to
consume it when a turn ended. unconfirmedPatchOnTurnEnd clears it on the
falling edge of running, and only there — the three-value decision about
when to show it is untouched.
…the engine facade

Migration step M3, batch B0 (engine-abstraction). 13 endpoints across
routes/plugins.js and routes/turn-diff.js reached the catalogue host by
importing lib/acp-client.js#getCatalogueHost directly. They now call
getEngineCatalogueHost() from the facade.

- engine/host.js: the lazy bridge. Its only import is a dynamic
  `await import("../lib/acp-client.js")` inside the function body, so
  engine/index.js gains a function and not a module load. That boundary
  is the whole point: app.js reaches engine/index.js through
  routes/engine-capabilities.js, and a static import of acp-client there
  would put the ACP client tree on every server start — the regression
  M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once
  declaration and construction were split). The value is forwarded
  verbatim, `null` included, so "host did not boot" stays
  RUNTIME_UNAVAILABLE and never a second host.
- engine/index.js re-exports the getter; the two routes import it from
  there and no longer name acp-client.js.
- No endpoint behaviour changes: same wire shapes, statuses, codes, same
  `deps.getCliService` / `deps.getDiffApplication` seams, same one
  process-wide host. Measured on the module graph: routes/plugins.js
  drops from 13 product files + @mavis/shared to 8 files and zero bare
  packages; engine/index.js's whole closure is 6 files and 0 bare
  specifiers. Server start and the facade's own load are unchanged
  (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot
  stays in the same 200-300ms band) because lib/state-bus.js already
  pulls acp-client into app.js's boot graph — closing that edge belongs
  to the catalogue read/write batches (M3-B1+), not here.

Tests: test/lib/engine/host-facade.test.js pins the contract against the
real module graph rather than against source text — a resolve hook
(module.registerHooks) in a fresh process reports, per parent, which
specifiers each entry resolved. It asserts neither route has a direct
edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js
pulls no host module and no @mavis/* or @minimax/* package, that
engine/host.js is in that closure, and the source-shape tripwires
(dynamic import only, facade re-export). Mutation-checked: making the
facade import statically turns 4 tests red, making plugins.js import
directly turns 4 more red. The existing plugins/turn-diff suites pass
unchanged under both transports (158 tests x acp and x runtime).

Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains
engine/host.js on top of the six files #143 + the doc batch settled, the
"one host" rule now names the facade, and the boot-path discipline is
stated where the file list lives. docs/webui.md + .zh-CN.md are
untouched: no user-visible change. Source inventory regenerated for the
two new files (rebase conflict in it was resolved by taking the upstream
copy and regenerating, never by hand).
…ites immune to the gate's isolation env

The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and
MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those
exports take away from them:

- config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the
  gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR
  fixture and every DB-backed case resolved null against a scratch dir that
  holds no runtime-state.sqlite. The suite now exports the name that wins.
- config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB ||
  join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports
  MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach
  still cleared join(DATA_DIR, "sessions.json") — so each run read the
  previous run's records and the mid-run switch resolved an id whose
  workspace belonged to a since-removed tmp dir. Both chat-route suites now
  pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears.

Test-only: no server/ code, no helper under test/helpers/_setup.js, and no
assertion weakened or skipped. Verified with the three variables set, with
MCODE_WEBUI_SESSIONS_DB additionally set, and bare.
…the engine facade

Migration step M3, batch B0 (engine-abstraction). 13 endpoints across
routes/plugins.js and routes/turn-diff.js reached the catalogue host by
importing lib/acp-client.js#getCatalogueHost directly. They now call
getEngineCatalogueHost() from the facade.

- engine/host.js: the lazy bridge. Its only import is a dynamic
  `await import("../lib/acp-client.js")` inside the function body, so
  engine/index.js gains a function and not a module load. That boundary
  is the whole point: app.js reaches engine/index.js through
  routes/engine-capabilities.js, and a static import of acp-client there
  would put the ACP client tree on every server start — the regression
  M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once
  declaration and construction were split). The value is forwarded
  verbatim, `null` included, so "host did not boot" stays
  RUNTIME_UNAVAILABLE and never a second host.
- engine/index.js re-exports the getter; the two routes import it from
  there and no longer name acp-client.js.
- No endpoint behaviour changes: same wire shapes, statuses, codes, same
  `deps.getCliService` / `deps.getDiffApplication` seams, same one
  process-wide host. Measured on the module graph: routes/plugins.js
  drops from 13 product files + @mavis/shared to 8 files and zero bare
  packages; engine/index.js's whole closure is 6 files and 0 bare
  specifiers. Server start and the facade's own load are unchanged
  (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot
  stays in the same 200-300ms band) because lib/state-bus.js already
  pulls acp-client into app.js's boot graph — closing that edge belongs
  to the catalogue read/write batches (M3-B1+), not here.

Tests: test/lib/engine/host-facade.test.js pins the contract against the
real module graph rather than against source text — a resolve hook
(module.registerHooks) in a fresh process reports, per parent, which
specifiers each entry resolved. It asserts neither route has a direct
edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js
pulls no host module and no @mavis/* or @minimax/* package, that
engine/host.js is in that closure, and the source-shape tripwires
(dynamic import only, facade re-export). Mutation-checked: making the
facade import statically turns 4 tests red, making plugins.js import
directly turns 4 more red. The existing plugins/turn-diff suites pass
unchanged under both transports (158 tests x acp and x runtime).

Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains
engine/host.js on top of the six files #143 + the doc batch settled, the
"one host" rule now names the facade, and the boot-path discipline is
stated where the file list lives. docs/webui.md + .zh-CN.md are
untouched: no user-visible change. Source inventory regenerated for the
two new files (rebase conflict in it was resolved by taking the upstream
copy and regenerating, never by hand).
…ites immune to the gate's isolation env

The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and
MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those
exports take away from them:

- config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the
  gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR
  fixture and every DB-backed case resolved null against a scratch dir that
  holds no runtime-state.sqlite. The suite now exports the name that wins.
- config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB ||
  join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports
  MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach
  still cleared join(DATA_DIR, "sessions.json") — so each run read the
  previous run's records and the mid-run switch resolved an id whose
  workspace belonged to a since-removed tmp dir. Both chat-route suites now
  pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears.

Test-only: no server/ code, no helper under test/helpers/_setup.js, and no
assertion weakened or skipped. Verified with the three variables set, with
MCODE_WEBUI_SESSIONS_DB additionally set, and bare.
…ransport (M3-B1)

The directory-read family — #9 acp-sessions, #10 acp-session-title, #72
protocol/list-sessions, #74 state, #75 health — reached the engine through
whatever MCODE_WEBUI_TRANSPORT happened to be, so "does the engine support
this" had no answer anywhere except the absence of a crash. server/engine/
session-reads.js gives it one: each endpoint declares the capability and the
provider method it needs, the facade checks the registered provider's
declaration first, and a provider that does not offer the read answers 501
through invokeHandler instead of an empty list.

Nothing on the wire moves. The facade forwards to the same acp-client
exports the routes already called, so the 30s cache, the cwd normalisation,
the 30s-stale sidebar push semantics and the catalogue-sessions projection
are the same code; handleHealth becomes async because the version now
resolves through the facade, which is why app-hono's legacy-parity helper
learned to await it. /api/state's snapshot field list is untouched —
snapshotViewFields and mcodeSessionsSnapshotFields are the first-frame render
contract and this batch adds and removes nothing.

Each read also reports where its bytes came from — catalogue, acp, or
acp-fallback when the runtime transport asked for a host that never booted.
That is metadata, not wire, and it is the difference between a sidebar that
degraded and one that pretends.

Two things this batch found rather than assumed: the catalogue host exposes
no version accessor, so /api/health keeps answering from the ACP initialize
mirror and says so rather than inventing a method; and protocol.js#72's old
test drove a mock key nothing read, so "the cwd filter works" had never
actually been proven.
…ade (M3-B2)

Routes #8 GET /api/session-tree and #11 GET /api/sessions/:id/export
through the engine facade instead of the transport, keeping every
response shape, status code and reason string unchanged.

The two families are separate files because their gate policies are
opposite. The tree is entirely engine data, so a provider that cannot
list sessions genuinely has no tree: assertSessionTreeCapability throws
and invokeHandler answers 501. Export's primary source is sessions.json
and the engine only contributes a best-effort transcript enrichment the
endpoint has always promised never to block on, so
checkSessionExportCapability reports and never throws — gating it hard
would delete working functionality in response to a declaration about a
capability the endpoint does not depend on. The tree route re-throws the
capability error, matched with the class's own instanceof helper rather
than a `.name` compare: `name` is a writable instance property, so a
stray `err.name = "…"` would silently turn that 501 back into the 200
soft-fail the gate exists to prevent. A test pins both halves — the real
class propagates, an impostor carrying the right `.name` does not.

Verified by exporting the real tree (303 rows, 32 projects, 299 nodes)
before and after and diffing every node's id/title/parent/depth: 3289
field comparisons, zero differences. A synthetic fixture covers what the
live data does not contain (orphans, cycles, four-level nesting, exotic
titles): 165 comparisons, zero differences. Two pre-existing shapes are
pinned because a "cleanup" would silently break them — child nodes carry
no `children` key (all 66 of them), and the response has no
parent_session_id key at all.
@fengzhi09
fengzhi09 enabled auto-merge (squash) October 2, 2026 12:50
@fengzhi09
fengzhi09 merged commit feedd73 into main Oct 2, 2026
17 checks passed
fengzhi09 added a commit that referenced this pull request Oct 2, 2026
main's #148 squash is a subset of dev-lhl content (every main blob is an
older revision of a file dev-lhl evolved, verified blob-by-blob against
e595c7c). Resolving toward dev-lhl loses nothing and advances the merge
base past the squash, unblocking PR #149.
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