Skip to content

Commit c213975

Browse files
fengzhi09claude
andauthored
feat(webui): M3-B11 move the provider family behind the engine facade (A5 dual-source merge) (#157)
* docs(webui): the engine layer has six files, not five (webui-parity 107) 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. * test(webui): M2 capability-declaration snapshot vs the real host (engine-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. * test(webui): point the capability snapshot at the engine layer's real 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. * fix(webui): stop the shell from carrying one session's state into another 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. * refactor(webui): the plugins and turn-diff routes take the host from 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). * test(webui): make the run-mirror, first-turn-guard and mavis-usage suites 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. * refactor(webui): the plugins and turn-diff routes take the host from 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). * test(webui): make the run-mirror, first-turn-guard and mavis-usage suites 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. * feat(webui): the five read endpoints ask the engine facade, not the transport (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. * feat(webui): the session-tree and export endpoints ask the engine facade (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. * feat(webui): the usage endpoints ask the engine facade, and the derived figures get one home (M3-B3) * fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, and record the whole-namespace mock trap * feat(webui): the account, model and capability reads ask the engine facade (M3-B4) #20 /api/account, #57 /api/models and #73 /api/protocol/capabilities now reach the engine through three new engine/ modules instead of naming lib/mcode-rpc.js, lib/models.js, lib/providers-config.js, lib/engine-catalogue.js and lib/acp-client.js themselves. Three modules because the three gate policies are all different: the account read gates HARD on authCredentials.getAccountStatus (the same provider method B3's #15/#16 read, so a provider that drops it takes both down together), the model catalogue gates SOFT (its primary sources are files webui owns, so a hard gate would delete a working picker), and #73 declares nothing at all because it IS the declaration endpoint. The model projection moved whole — three sources, the per-provider dedupe, both builtin-tree annotations and the three derived figures are now named pure functions pinned on their inputs, and #57 is verified by a full snapshot whose oracle was captured from the pre-refactor implementation. Its read stays synchronous so handleGetModels keeps its contract, which is also why engine/model-reads.js is not re-exported from engine/index.js: its four sources reach @mavis/shared and js-yaml, and the boot-path guard is right to refuse that under the shared facade. #73 is the one response body in the migration that changes: it gains an `engine` key carrying the engine-capabilities view, with `providerFor` saying whether the declaration came from the active transport's provider or from the default one standing in. Every pre-existing key keeps its name, position and value, and the ACP wire table is not replaced by the 14 matrix keys. * feat(webui): #73 swaps the ACP wire table for the 14-key engine-capabilities view (user-approved contract change) `GET /api/protocol/capabilities` used to answer from two hand-maintained places: `MCODE_ACP_CAPABILITIES`, a flat `{method: boolean}` table of the ACP JSON-RPC surface, and the `initialize` agentInfo mirror. The engine's DECLARED capability surface already existed — the 14-key per-provider object that `GET /api/engine-capabilities` serves — so webui was carrying two parallel answers to "what can this engine do", able to disagree, with no test able to notice. This makes `capabilities` the declared object and drops the wire table from the endpoint. This is a reviewed, user-authorised endpoint contract change, not a refactor side effect, and it is stated as such in the module header, in `docs/API.md` and in both ARCHITECTURE twins. The twelve old accessors are asserted GONE, so a consumer reading `capabilities.set_mode` gets undefined and fails loudly rather than receiving a truthy object field. No runtime consumer exists: nothing in `webapp/` reads this endpoint, and `engine/capability-reads.js` no longer imports `lib/mcode-rpc.js` at all (pinned by a static tripwire, because an unused import is behaviourally inert and no behavioural test could see it). The `engine` key the previous commit added is REMOVED rather than kept: with `capabilities` already the declaration, an `engine` block would carry the same 14 keys a second time in one response. What survives from that shape is the provenance — `capabilitiesProvider` / `capabilitiesProviderFor`, the honest bit that says whether the declaration came from the active transport's provider or from the default one standing in — plus `capabilitiesUnavailable` for the derived degradation roll-up. A test counts the declaration's occurrences in the serialised body and requires exactly one, so a second carrier is a red bar. `MCODE_ACP_CAPABILITIES` is kept and stays pinned by `test/lib/mcode-rpc.check.mjs`: it is still a true statement about the ENGINE's ACP surface and `docs/CAPABILITIES.md` cites it as one. It has no webui consumer left, recorded as debt in the module header rather than deleted as a side effect. `docs/webui.md` and `docs/webui.zh-CN.md` gain a diff here for the first time in this migration: they carried the old response shape in their endpoint tables, and an authorised contract change has to be documented where the contract is written. * fix(webui): stop two B4 comments describing behaviour the code no longer has Found by the dual-axis code review (Standards + Spec) before B4 merges. No logic changes; both were documentation lying about the code next to it. `engine/index.js` still described #73 as an additive change — "the response body gains a key (`engine`, the engine-capabilities view) … additive rather than a replacement". The second B4 commit made it a REPLACEMENT and deleted the `engine` key, so the facade's own export table was the one place still telling a reader the opposite of what the endpoint does. It now states the replacement, why the `engine` key was removed rather than kept, and what survived from it (the provenance keys and the derived roll-up). `docs/ARCHITECTURE.md` and its zh-CN twin called `engine/account-reads.js`'s read **synchronous**. It is `async` — `readEngineAccount` awaits a `Promise.all` of dynamic imports — and the boot-path note the row pointed at describes model-reads, not this module. The same two documents already listed account-reads correctly under the `await import()` rule a few paragraphs down, so the file contradicted itself in two languages at once. Both rows now say asynchronous and point at the ordinary rule. Also drops a dead `assertEngineCapability` import from `engine/model-reads.js`: the soft gate inspects the declaration inline, so the throwing helper was never called and its presence read as if the soft path could still throw. Replaced by a comment saying why it is absent, so the next reader does not "fix" it back in. And the one comment with Chinese embedded mid-sentence (仓库 review 要求注释用英文) is now English; the header parentheticals naming each family (账户读 / 模型目录读 / 能力声明读) stay, as do the quoted product strings — `本地用户` is the real zh-CN value of `userMenu.localUser` and `shell.tsx` cites it the same way. * feat(webui): move the session write family behind the engine facade M3 batch B5: #7 DELETE /api/sessions/:id, #4 POST /api/sessions/rename and #6 POST /api/sessions/cleanup-orphans stop driving the store, the caches and the engine's own local_runtime_* tables from the route. They ask engine/session-writes.js instead, so the load -> resolve -> authorize -> intent-audit -> mutate ordering — and the resurrection guard inside it — becomes named, testable code rather than a two-line helper a route could call out of order. #7 and #6 gate HARD on sessionCrud.deleteSession, because the rows they destroy are the engine's own; #4 declares no capability at all, because a rename writes webui's own store and touches no engine surface. The policy is decided by who owns the rows the write destroys, which is a different question from the read families' and does not have the same answer twice in a row here. The facade exposes a plan/commit pair rather than one deleteSession(), so the write-ahead audit still lands between "know what the user asked to delete" and "delete it". Response bodies are built in the facade once, which is what lets the #6 and #7 dryRun shapes be pinned byte-for-byte by unit tests. No status code, response body or error code changes. The 32-table delete SQL stays in lib/mcode-session-delete.js and is reached by dynamic import; acp-client.js and four test files bind to that specifier, so collecting it is a later batch's job. Recorded as KNOWN DEBT, along with rename writing a webui-side label only, and delete not detecting an in-flight session. * fix(webui): drop whitespace text nodes in markdown tables and dedupe thinking label * fix(webui): sweep the non-flipping inverted text token off primary surfaces The authorize-button fix (08599472) surfaced five more primary surfaces pairing text-text_default_inverted_static with bg_interaction_primary_default; dark mode inverts that background to pure white while the token stays near-white, so the label composites to white-on-white. Swap all of them to text-text_label_primary_default and add a source-scan guardrail that keeps the pairing out of primary surfaces while pinning the sanctioned status-badge exception (toolbar). * feat(webui): move session switch behind the engine facade M3-B6: #3 POST /api/sessions/switch now asks the engine facade instead of reaching into lib/acp-client.js, lib/transcript.js, lib/mavis-usage.js, lib/models.js and lib/config.js from the route. The new engine/session-switch.js owns the four load-bearing facts the ~290-line handler had accumulated: the mvs-sid-first resolution order (single base-session identity), the backfill decision and its read, the workspace containment gate (which runs before any cs mutation, so a refused switch leaves the client untouched), and the response body. The gate is SOFT — it reports and never throws — because the switch's primary data is webui's own record and both engine touches have a defined degradation. Gating hard would remove a working endpoint over a title and a transcript, and would do it on the default acp transport first. The route keeps what is its own: the "id required" 400, the status mapping, the fail-closed audit and the SSE push — the audit has to land after the switch has already mutated cs, and the push must not fire when it fails. Behaviour is unchanged and pinned: the four red lines (transcript backfill, cumulative detection, workspace containment, single base session identity) each get named tests with their negative half, and the success body's key ORDER is compared as a string. Six mutations of the facade were run to prove the tests are load-bearing. KNOWN DEBT 1 in the new module records what this batch did NOT retire: the 3-candidate transcript probe. The default acp transport has no engine surface to replace it with, getMessages paginates where the probe caps lines, their orderings differ, and export's enrichment is still byte-pinned to the same candidates. What IS retired is the coupling — the route no longer names lib/transcript.js, and the probe list is an implementation detail behind one seam. * chore: allowlist the leak-tripwire fixture in model-reads tests gitleaks' generic-api-key rule flags the deliberate sk-secret-should- never-leak fixture that model-reads.test.js uses as a leak-prevention tripwire (asserting the facade never serializes provider keys). The value is fake and the assertion exists to catch real leaks; allowlist the exact pairing instead of weakening the fixture. * test(webui): pin session-writes cleanup-orphans test to isolated paths * chore: ignore gitleaks fingerprints of deliberate test fixtures The full-history scan flags two synthetic-credential fixtures: the model-reads leak tripwire (fake provider keys asserting the facade never serializes them) and the fs-credential-guard canaries (fake id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in .gitleaksignore; the .gitleaks.toml path allowlist for the same files stays as a coarse first line. * chore: make the gitleaks fixture allowlists path-only The match-targeted entry missed the byok fixture key (the generic rule's match string differs from the tripwire value the entry was written for). Scope both entries to the two fixture files themselves — every finding in them is synthetic by construction — and keep .gitleaksignore as the precise fingerprint layer. * feat(webui): move interrupt and load endpoints behind the engine facade * fix(webui): take the plan's 5s abort force-kill bound by product call The batch plan transcribed the abort bound as 5s; the migrated file ran 2000ms. The product call (2026-10-03) takes the plan's value: the longer grace gives a stubborn child more time to finalize at the cost of 'already stopped' staying a lie for three extra seconds. The pinning test moves with it and KNOWN DEBT 1 records the resolution. * docs(webui): add session-switch, interrupt and session-load to the architecture map * docs(webui): add the missing zh-CN section for the B5 write family * fix(webui): make webui-only session delete return promptly instead of hanging * fix(webui): retire lossy streaming mirrors when the engine transcript arrives * feat(webui): add the streaming-send capability gate and pure stream bridge M3-B8a (1 of 2) splits M3-B8 at the boundary the plan drew but the original batch crossed. This commit ships the PURE layer of the #12 send family: the streamingSend capability declaration, the HARD gate that enforces it, and the eight derivations that turn the runtime's stream vocabulary into webui's chat-line vocabulary. NO USER-VISIBLE CHANGE. The gate and the derivations are in place and tested, but nothing calls them: POST /api/send is not wired to a runtime branch yet and behaves exactly as it did at 32277c3a on every transport. Both the acp and the runtime failure sets are the pre-existing baseline core. M3-B8b adds the runner and the route branch and lights the transport up. The family gates HARD, the first M3 family to do so, because #12's response is {ok:true} written BEFORE the engine is called: a provider with no send surface could only be answered with an ack for a turn that never runs, and there is no truthful degradation to fall back to. The gate is deliberately unreachable today (v2 declares streamingSend: full; acp has no registered provider until M4), and the suite pins both halves of that so making it reachable is a deliberate edit. * feat(webui): run send on the runtime transport behind the engine facade M3-B8b (2 of 2) lights up POST /api/send on the runtime transport. It adds the half B8a deliberately left out: the runner, the route branch, and the data plane that opens a turn's event stream. The acp path is unchanged. runMcodeAcp and streamAcpPrompt were not edited, and the route's post-run tail — the finalize drain, promoteDraftToMcodeSid, persistCurrentChat, pushStateFor — is untouched, because the runtime runner resolves to the same result shape and writes through the same run-chat buffer. That is what makes run-mirror, the drain and the single-identity promotion structural rather than re-implemented: they cannot differ between transports. MCODE_USE_ACP=0 still outranks the new branch, as lib/config.js documents — the escape hatch exists for the moment a transport misbehaves, so an operator must not have to unset a second variable first. The three route-level test files that install a fake ACP transport now pin MCODE_WEBUI_TRANSPORT=acp at module scope. Before B8b the send endpoint had no sibling to follow, so no test had to declare which transport it was written against; that declaration is what stops an acp-path contract from silently becoming a runtime-path one. Recorded in engine/streaming-send.js: /api/stop cannot stop a runtime turn and says so; attachments reach the runtime without a mime type; the context limit is not bridged from the stream; a first runtime turn does not receive the user's model pick; transport selection is still an env read rather than a registry lookup. * feat(webui): answer set-mode and set-config-option with structured 501 when the capability is absent * docs(webui): add the streaming-send architecture section, bilingual * docs(webui): add the streaming-send architecture section, bilingual * fix(local-runtime): make an abandoned migration lease recoverable at startup The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a live holder heartbeats the directory mtime every `stale / 2`. A process killed between the two leaves the directory behind, and an abandoned lease is then indistinguishable from a held one except by that mtime. The window was 30 minutes while the retry budget was 120 attempts at this backoff shape — about 55 seconds. A waiter could not outlast the window, so every engine launch during it spent the whole budget and then died with `agent_name_conflict_migration_failed:lock`. One killed process therefore made `mcode acp` unstartable for half an hour, and each blocked launch produced no answer, no engine process and no session — the reported send regression. The stale window drops to 2 minutes. That does not weaken the safety property: proper-lockfile derives the heartbeat from the stale window, so "two missed heartbeats before the lease is called abandoned" is unchanged, and the migration re-inspects under the lease, so the worst a wrongly-considered stale lease costs is one extra inspection rather than a double rewrite. The retry budget rises to ~195s so a waiter survives one expiry and acquires instead of dying at the moment the lease becomes reapable. Verified against the live data directory: an orphaned lease took the engine from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned a real model reply with zero `acp exited` events. * feat(webui): move model and permission writes behind the engine facade * Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14+B8a/B8b) after push-order rollback * fix(webui): surface truncated acp stderr in failure alerts * feat(webui): move the provider family behind the engine facade with storage migration * fix(webui): acknowledge in-flight messages explicitly instead of echoing into a void * fix(webui): normalise the expected side of the provider cwd path assertion The macOS verify red was a TEST defect, not a product one. The product never resolved a provider path beyond what its resolver returned: `sources.cwd` is `join(process.cwd(), "models.json")`, and `process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on every POSIX platform. The assertion built its expectation from the literal string the test had chdir'd into, so the two agreed only when the temp path had no symlink component — true on Linux CI, false on macOS, where `/var` is a symlink to `private/var`. Normalising the EXPECTED side is the fix, and the behaviour is now pinned rather than assumed: - the assertion states the contract (`process.cwd()` + the file name) instead of re-deriving it; - a named regression test drives a real symlinked cwd and asserts webui applies no second resolution of its own; - the same symlink machinery is applied to the WRITE path, where the question is a security one: the store is 0600 via tmp+rename and carries every plaintext apiKey, so a write that resolved its path differently from the read would put the keys in a file the catalogue never reads; - a source tripwire fails if a `realpath` (or equivalent) is ever added to the provider path resolution, so the symptom is not "fixed" in product code next time. Both the symlink behaviour and the tripwire were verified to bite: a `realpathSync` injected into `loadProvidersConfig` turns the symlink test, the sources test and the tripwire red. The whole B11 suite was re-run with TMPDIR pointed at a symlinked directory, which reproduces the macOS `/var` condition on Linux: 149 tests pass. The pre-fix assertion fails under exactly that condition and the post-fix one passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(webui): bridge thinkingEffort as the third config id and gate the model and permission writes --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4d19902 commit c213975

48 files changed

Lines changed: 7164 additions & 2061 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/webui.md‎

Lines changed: 132 additions & 5 deletions
Large diffs are not rendered by default.

‎docs/webui.zh-CN.md‎

Lines changed: 119 additions & 5 deletions
Large diffs are not rendered by default.

‎packages/webui/docs/API.md‎

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ not a total-turn ceiling.
191191
`"at-capacity"` (the server is at `MAX_CONCURRENT`, which `/api/health`
192192
reports as `maxConcurrent`)
193193

194+
**A 409 is terminal for that message, and it is not a failure.** The turn is
195+
never handed to the engine, the `›` line is never written, and nothing reaches
196+
the persisted record — a refused send cannot be half-applied, and cannot be
197+
one the engine ran while the transcript lost. There is no send queue: "refused,
198+
try again when the turn ends" is the whole contract.
199+
200+
`error` is the user-facing sentence (the composer renders it verbatim) and
201+
`reason` is the stable machine-readable key; branch on `reason`. For
202+
`cid-busy` and `session-busy` that sentence states the conversation is already
203+
running a turn and the message was not delivered, rather than repeating the
204+
internal detail — which reads "another window" and is wrong for the common
205+
case of the same tab sending again a moment later.
206+
194207
### `POST /api/stop`
195208

196209
Cancel the current run. Tries `session/cancel` via acp (the cancel
@@ -1973,9 +1986,14 @@ actually read for each layer, so an operator can confirm which file
19731986
the live config came from.
19741987

19751988
Layered resolution: `MCODE_WEBUI_MODELS_CONFIG` env → cwd `models.json`
1976-
→ user-level `~/.mcode-webui/providers.json` (the PUT write target).
1977-
Same-id provider deep merge; models dedupe by id with the higher layer
1978-
winning.
1989+
→ the engine's `<engine data dir>/config.yaml` under `custom_provider`
1990+
(the PUT write target). Same-id provider deep merge; models dedupe by id
1991+
with the higher layer winning.
1992+
1993+
The env and cwd layers are deployment-owned and are never written by
1994+
any handler. The third layer used to be a webui file of its own
1995+
(`~/.mcode-webui/providers.json`); it is now the engine's own provider
1996+
store, and that file is **deprecated** — see "Provider storage" below.
19791997

19801998
**Response 200**
19811999
```json
@@ -2014,26 +2032,70 @@ winning.
20142032
}
20152033
```
20162034

2035+
- `sources.user` and `userPath` still name the **deprecated**
2036+
`~/.mcode-webui/providers.json`. The fields did not change and the
2037+
values did not either: both are documented as "the files this server
2038+
resolved", and an operator diagnosing a missing provider still needs
2039+
to be told what to look at. What changed is the answer — the file is
2040+
read only until the migration completes, and is never written again.
2041+
The live catalogue is the engine store; `GET /api/models` reads it
2042+
there too.
20172043
- `auth.apiKeyMasked` is the only apiKey shape returned by any route
20182044
in this surface. A test (and `scripts/check-docs-alignment.mjs`)
20192045
pins the rule: the plaintext key MUST NEVER appear in any
20202046
`/api/providers*` response, regardless of which layer held it.
2047+
**Path forms are reported as the server resolved them, and nothing is
2048+
re-resolved.** `sources.cwd` is `<process.cwd()>/models.json`, and
2049+
`process.cwd()` is the kernel-reported working directory — on macOS
2050+
that is the fully-resolved form, so a server started under `/var`
2051+
reports `/private/var/...`. That is the correct answer to "which file
2052+
did you read", and the write side uses the same resolver, so the file
2053+
the response names is the file the `PUT` will land in. `sources.user`
2054+
and `userPath` come straight from `MCODE_WEBUI_DATA_DIR` and are
2055+
reported exactly as configured.
2056+
20212057
- `sources.env` is `null` when `MCODE_WEBUI_MODELS_CONFIG` is unset;
20222058
`sources.cwd` is omitted from the layer set in that case (the env
20232059
override is the cwd file).
20242060

20252061
### `PUT /api/providers`
20262062

2027-
Validate-and-persist a v2 provider config to the user-level file
2028-
(`~/.mcode-webui/providers.json`, the file written by this handler).
2029-
The env / cwd layers are deployment-owned and never written here.
2030-
2031-
The handler atomically writes via rename (no half-written file on
2032-
disk), reloads the layer set on the next call, and broadcasts an
2033-
SSE `providers.updated` named event with the masked payload so
2034-
every connected client refreshes its catalogue without polling.
2035-
`/api/models` picks up the change on the next request — no restart
2036-
required.
2063+
Validate-and-persist a v2 provider config to the **engine's provider
2064+
store** — `<engine data dir>/config.yaml` under `custom_provider`,
2065+
written with mode `0600`. The env / cwd layers are deployment-owned and
2066+
never written here, and neither is the deprecated
2067+
`~/.mcode-webui/providers.json`.
2068+
2069+
The handler performs **one** write: a temporary file plus a single
2070+
`rename` of the whole document. There is no second file to fall out of
2071+
step, so a request either lands completely or changes nothing — a
2072+
concurrent reader always sees a whole catalogue, never a mixture, and
2073+
never a partially written YAML document. The layer set is re-read on
2074+
the next call, and the handler broadcasts an SSE `providers.updated`
2075+
named event with the masked payload so every connected client refreshes
2076+
its catalogue without polling. `/api/models` picks up the change on
2077+
the next request — no restart required.
2078+
2079+
**Capability gate.** The two write endpoints (`PUT /api/providers`
2080+
and `POST /api/providers/preset/:id/enable`) declare
2081+
`authCredentials` and gate **hard** on their sub-item
2082+
(`updateUserModelProvider` / `createUserModelProvider`). A provider
2083+
that declares the sub-item absent answers
2084+
`501 {ok:false, code:"engine_capability_not_supported", …}` rather than
2085+
acknowledging a configuration the engine will never read. On the
2086+
default `acp` transport no provider is registered yet, so the gate
2087+
reports `unregistered-transport` and the write proceeds. The three read
2088+
endpoints declare the same capability and gate **soft** — they report
2089+
degradation and keep serving.
2090+
2091+
**Legacy migration.** While the engine store carries no migration
2092+
marker, the deprecated `providers.json` is still the authority: webui
2093+
folds it into the store, losslessly, on the next read, and stamps the
2094+
marker on success — after which the file is never read again. A failed
2095+
migration (an unparseable `config.yaml`, a write that could not
2096+
complete) leaves the store untouched and the old format readable, and
2097+
the next read retries. Field-by-field equivalence is pinned by
2098+
`packages/webui/test/lib/engine/provider-migration.test.js`.
20372099

20382100
**Request**
20392101
```json
@@ -2059,15 +2121,27 @@ required.
20592121
{
20602122
"ok": true,
20612123
"providers": [ /* masked view, same shape as GET */ ],
2062-
"path": "/home/you/.mcode-webui/providers.json"
2124+
"path": "/home/you/.minimax/config.yaml",
2125+
"engineSync": { "ok": true, "written": true, "keys": ["openai_compat"] }
20632126
}
20642127
```
20652128

2129+
- `path` is the file this handler wrote: the engine's `config.yaml`.
2130+
It used to be `~/.mcode-webui/providers.json`.
2131+
- `engineSync` reports the store write itself. `written: false` means
2132+
the document would have come out unchanged (a no-op PUT does not
2133+
re-chmod a file an operator just hand-edited). It is `ok: true`
2134+
whenever the store accepted the write.
20662135
- `400 BAD_BODY` — invalid provider shape, unknown protocol, or
20672136
validation failure (each error carries a human-readable `error`
20682137
string with the offending field).
2069-
- `500 WRITE_FAILED` — disk I/O failure (the in-memory state did
2070-
not change; the operator should retry).
2138+
- `500 WRITE_FAILED` — the store refused or could not perform the
2139+
write. Two causes, and the second is the one that matters: a
2140+
`config.yaml` that does not parse is **refused, never
2141+
overwritten**, because rewriting it would destroy every engine
2142+
setting the store does not own. In both cases the previous
2143+
document is intact, the next `GET` returns the catalogue the client
2144+
already had, and the operator can retry.
20712145

20722146
### `POST /api/providers/test`
20732147

‎packages/webui/docs/API.zh-CN.md‎

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ stdin。
175175
`"session-busy"`(另一个客户端正在跑这个会话)或 `"at-capacity"`
176176
(服务端已达 `MAX_CONCURRENT`,即 `/api/health` 里报的 `maxConcurrent`)
177177

178+
**409 对这条消息是终态,而且它不是失败。** 这个回合不会交给引擎,`›` 行
179+
不会写入,落库记录里也不会有任何东西——被拒的发送不可能被半途应用,也不可能
180+
出现「引擎跑了、转录却丢了」的情形。这里没有发送队列,契约就是「被拒,回合
181+
结束后再发」。
182+
183+
`error` 是面向用户的句子(composer 逐字渲染它),`reason` 是稳定的机读键;
184+
按 `reason` 分支。`cid-busy` 与 `session-busy` 的句子说明「本会话正在跑一个
185+
回合,这条消息未送达」,而不是复述内部 detail——后者写的是「另一个窗口」,
186+
对「同一个标签页隔一会儿再发一次」这个常见情形是错的。
187+
178188
### `POST /api/stop`
179189

180190
取消当前运行。先尝试通过 acp 调用 `session/cancel`
@@ -1812,8 +1822,14 @@ Multipart 文件上传。保存到 `MCODE_WEBUI_UPLOAD_DIR` 并返回
18121822
现网配置来自哪个文件。
18131823

18141824
分层解析顺序:`MCODE_WEBUI_MODELS_CONFIG` 环境变量 → cwd 下的
1815-
`models.json` → 用户级 `~/.mcode-webui/providers.json`(PUT 的写入
1816-
目标)。同 id 的 provider 做深合并;模型按 id 去重,高层胜出。
1825+
`models.json` → 引擎的 `<引擎数据目录>/config.yaml` 里的
1826+
`custom_provider` 节点(PUT 的写入目标)。同 id 的 provider 做深
1827+
合并;模型按 id 去重,高层胜出。
1828+
1829+
env 与 cwd 两层由部署方拥有,任何 handler 都不写。第三层过去是
1830+
webui 自己的文件(`~/.mcode-webui/providers.json`),现在是引擎
1831+
自己的 provider 存储;那个文件已**废弃**,详见下文 `PUT /api/providers`
1832+
一节。
18171833

18181834
**响应 200**
18191835
```json
@@ -1856,14 +1872,30 @@ Multipart 文件上传。保存到 `MCODE_WEBUI_UPLOAD_DIR` 并返回
18561872
与 `scripts/check-docs-alignment.mjs` 一起把这条规则钉死:无论
18571873
密钥来自哪一层,明文 key 都绝不允许出现在任何
18581874
`/api/providers*` 响应中。
1875+
- `sources.user` 与 `userPath` 仍然指向**已废弃**的
1876+
`~/.mcode-webui/providers.json`。字段没变,取值也没变:两者的
1877+
文档语义都是「服务端解析了哪些文件」,运维排查 provider 缺失时
1878+
仍然需要知道该看哪里。变的是答案——该文件只在迁移完成前被读取,
1879+
此后不再被写入。真正的目录在引擎存储里,`GET /api/models` 也
1880+
是从那里读的。
1881+
**路径按服务端解析出的形态上报,不做二次解析。** `sources.cwd` 是
1882+
`<process.cwd()>/models.json`,而 `process.cwd()` 是内核返回的工作
1883+
目录——在 macOS 上它是完全解析后的形态,因此从 `/var` 下启动的服务会
1884+
上报 `/private/var/...`。这正是「你到底读了哪个文件」的正确答案;
1885+
写入侧用的是同一个解析器,所以响应里指名的文件就是 `PUT` 会落到的
1886+
文件。`sources.user` 与 `userPath` 直接来自 `MCODE_WEBUI_DATA_DIR`,
1887+
按配置原样上报。
1888+
18591889
- `MCODE_WEBUI_MODELS_CONFIG` 未设置时 `sources.env` 为 `null`;
18601890
此时 `sources.cwd` 也从层级集合中省略(环境变量覆盖的就是 cwd
18611891
那个文件)。
18621892

18631893
### `PUT /api/providers`
18641894

1865-
校验并持久化一份 v2 provider 配置到用户级文件
1866-
(`~/.mcode-webui/providers.json`,即本 handler 写入的文件)。
1895+
校验并持久化一份 v2 provider 配置到**引擎的 provider 存储**——
1896+
`<引擎数据目录>/config.yaml` 的 `custom_provider` 节点,文件权限
1897+
`0600`。env / cwd 两层由部署方拥有,本 handler 不写;已废弃的
1898+
`~/.mcode-webui/providers.json` 同样不写。
18671899
env / cwd 两层归部署方所有,永远不在这里被写。
18681900

18691901
handler 通过 rename 原子写入(磁盘上不会出现半写文件),下一次
@@ -1895,14 +1927,40 @@ handler 通过 rename 原子写入(磁盘上不会出现半写文件),下
18951927
{
18961928
"ok": true,
18971929
"providers": [ /* 掩码视图,形态与 GET 相同 */ ],
1898-
"path": "/home/you/.mcode-webui/providers.json"
1930+
"path": "/home/you/.minimax/config.yaml",
1931+
"engineSync": { "ok": true, "written": true, "keys": ["openai_compat"] }
18991932
}
19001933
```
19011934

1935+
- `path` 是本 handler 实际写入的文件:引擎的 `config.yaml`。它
1936+
过去是 `~/.mcode-webui/providers.json`。
1937+
- `engineSync` 报告这次存储写入本身。`written: false` 表示文档
1938+
内容不会变化——空转的 PUT 不会去重设运维刚手工编辑过的文件权限。
1939+
只要存储接受了写入,它就是 `ok: true`。
1940+
1941+
**能力门控。** 两个写端点(`PUT /api/providers` 与
1942+
`POST /api/providers/preset/:id/enable`)声明 `authCredentials`
1943+
能力,并对自己的子项(`updateUserModelProvider` /
1944+
`createUserModelProvider`)做**硬**门控。声明缺失该子项的 provider
1945+
会得到 `501 {ok:false, code:"engine_capability_not_supported", …}`,
1946+
而不是确认一份引擎永远不会读取的配置。在默认的 `acp` 传输下尚未
1947+
注册任何 provider,门控报告 `unregistered-transport`,写入照常进行。
1948+
三个读端点声明同一能力,做**软**门控——只报告降级,继续服务。
1949+
1950+
**存量迁移。** 引擎存储里没有迁移标记时,已废弃的
1951+
`providers.json` 仍然是权威来源:webui 会在每次读取时尝试把它
1952+
无损折叠进存储,成功后写入标记,该文件此后再不被读取。迁移失败
1953+
(引擎配置无法解析、写入失败)时存储保持原样,旧格式继续可读,
1954+
下一次读取会重试。目录字段逐项等价由
1955+
`packages/webui/test/lib/engine/provider-migration.test.js` 钉死。
1956+
19021957
- `400 BAD_BODY` —— provider 形态非法、协议未知,或校验失败
19031958
(每条错误都带一条可读的 `error` 文本,指出出问题的字段)。
1904-
- `500 WRITE_FAILED` —— 磁盘 I/O 失败(内存中的状态没有变化;
1905-
运维应重试)。
1959+
- `500 WRITE_FAILED` —— 存储拒绝或未能完成写入。两种成因,其
1960+
中第二种才是重点:无法解析的 `config.yaml` 会被**拒绝,绝不覆盖**,
1961+
因为覆盖会连带毁掉存储并不拥有的全部引擎配置。两种情况下前一份
1962+
文档都保持完整,随后的 `GET` 返回客户端原本就有的目录,运维可以
1963+
直接重试。
19061964

19071965
### `POST /api/providers/test`
19081966

@@ -2018,8 +2076,11 @@ SSE 事件,让每个已连接客户端刷新目录。下一次 `/api/models`
20182076
```
20192077

20202078
- `400 UNKNOWN_PRESET` —— `:id` 不是已知模板。
2021-
- `500 WRITE_FAILED` —— 磁盘 I/O 失败(内存中的状态没有变化;
2022-
运维应重试)。
2079+
- `500 WRITE_FAILED` —— 存储拒绝或未能完成写入。两种成因,其
2080+
中第二种才是重点:无法解析的 `config.yaml` 会被**拒绝,绝不覆盖**,
2081+
因为覆盖会连带毁掉存储并不拥有的全部引擎配置。两种情况下前一份
2082+
文档都保持完整,随后的 `GET` 返回客户端原本就有的目录,运维可以
2083+
直接重试。
20232084

20242085
---
20252086

‎packages/webui/docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ is what lets it be re-exported from `engine/index.js` at all.
608608
static imports, because `routes/model.js` already imported all four
609609
**before** M3-B4 and the server's boot cost is therefore exactly what it
610610
was. They reach `@mavis/shared/local-runtime-paths` (via `lib/config.js`)
611-
and `js-yaml` (via `engine-provider-sync.js`), so the module is deliberately
611+
and `js-yaml` (via `engine/provider-store.js`), so the module is deliberately
612612
**not** re-exported from `engine/index.js`: making the shared facade — the
613613
one import site the whole server shares, and the one `routes/plugins.js`
614614
must stay light through — heavier than it has ever been would buy nothing.

‎packages/webui/docs/ARCHITECTURE.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ handler 层测试因此保持封闭。
556556
`lib/engine-catalogue.js`、`lib/models.js`、`lib/providers-config.js`——
557557
是静态 import,因为 M3-B4 之前 `routes/model.js` 就静态 import 了这四个,
558558
所以 server 的启动成本分文未增。但它们会经 `lib/config.js` 抵达
559-
`@mavis/shared/local-runtime-paths`、经 `engine-provider-sync.js` 抵达
559+
`@mavis/shared/local-runtime-paths`、经 `engine/provider-store.js` 抵达
560560
`js-yaml`,所以这个模块**刻意没有**从 `engine/index.js` 转发导出:让
561561
共享门面——整个 server 唯一的共享 import 站点,也是
562562
`routes/plugins.js` 必须保持轻量的那个——比它历来更重,换不来任何东西。

0 commit comments

Comments
 (0)