Skip to content

Commit d856c2a

Browse files
authored
feat(webui): P13 authorize fast-fail, P14 mirror retirement, M3-B8 send runtime lighting (#152)
* 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.
1 parent 12b1ac3 commit d856c2a

22 files changed

Lines changed: 4919 additions & 17 deletions

‎docs/webui.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,6 +2186,7 @@ do not come from the same place.
21862186
| Kind | Written by | In the engine runtime DB? | Survives a poll tick? |
21872187
| --- | --- | --- | --- |
21882188
| engine turn (`› ping`, `● pong`, tool blocks) | the engine, streamed into `cs.chat` | yes | yes, refreshed from the DB |
2189+
| the streaming **mirror** of that turn (one folded `● answer…`, a `→ bash` header without its args) | the same stream, into the same array | yes — the same text, folded | **no — it retires**, the engine's own lines take its place |
21892190
| `/api/cmd` echo (`› /help`, `● 可用命令:…`, `● 当前 model=…`, `● 变更概览 …`) | `interaction/commands.js`, into `cs.chat` | **no — the engine never sees it** | yes, and it is the only thing that keeps it there |
21902191
| a turn another client ran (desktop app, TUI) | the engine, for a different cid | yes | yes, pulled in — that is the poll's purpose |
21912192

@@ -2196,6 +2197,31 @@ catches up in an open tab. It is a **merge**, not a replacement:
21962197
the lines already shown in lockstep, keeps any line the engine does not
21972198
know about in place, and appends the engine's remainder.
21982199

2200+
The mirror is the third kind, and it is the one the merge has to retire. While
2201+
a turn streams, the same engine output is written into `cs.chat` a second time
2202+
in a folded form — the answer and thinking branches write one line
2203+
(`prefix + text.replace(/\n+/g, " ").trim()`) where the engine's own mapper
2204+
keeps one array entry per source line, and a tool header is written `→ bash`
2205+
when the frame carried no `rawInput` against the engine's
2206+
`→ bash {"command":…}`. Neither can ever be byte-equal to what the engine
2207+
holds, so the lockstep walk called every mirror a locally-authored line, kept
2208+
it, and appended the engine's whole spine behind it. The answer, the tool block
2209+
and the thinking chain each rendered twice, and `persistCurrentChat` made the
2210+
duplicate permanent. Measured on a UAT session: 81 stored lines against a
2211+
67-line engine read, 14 of them a second copy of engine content.
2212+
2213+
Retirement is an identity test, not a shape heuristic. A folded prose mirror
2214+
(`●`/`▲`/`›`/`○`) retires when the maximal run of engine lines carrying the
2215+
same glyph, starting at the cursor, folds — their texts joined by a single
2216+
space, every whitespace run collapsed — to exactly the mirror's folded text. A
2217+
tool header retires when the engine line at the cursor names the same tool, and
2218+
the indented block goes with it on both sides. So the engine must already hold
2219+
that text at that position: a `/api/cmd` echo, which the engine has never seen,
2220+
has no fold to match and is kept. And a test that misses — unusual spacing, a
2221+
tool block the engine has not finished writing — leaves the line in place, which
2222+
is the double render the merge already had. No path drops content the engine
2223+
read did not account for.
2224+
21992225
Server-written annotations — `§§ processed_duration=Nms`, `§§ turn_msg=<id>`,
22002226
`##tc:<id>` — are the one class of engine line the merge may not treat as an
22012227
ordinary line, because position is their entire meaning: the decoder resolves
@@ -2445,6 +2471,28 @@ authorize round-trip (5-minute default timeout, fail-closed):
24452471
The whitelist is the only source of truth — anything not on this list
24462472
cannot be gated via the modal flow.
24472473

2474+
**The 5-minute budget answers "a human saw the modal and did not answer".
2475+
It does not answer "no human was ever shown one".** The gate is a push:
2476+
`pushAuthRequest` writes a `needs_authorization` frame into the requesting
2477+
tab's SSE response, and the decision comes back on `POST
2478+
/api/auth/decision`. When the request's client has no live connection
2479+
(`state-bus.js#hasDecisionListener` — no response registered for that cid,
2480+
or the registered one can no longer be written to), nobody can decide, so
2481+
the fail-closed result is already determined. The request is answered at
2482+
once with the ordinary decline body — `403 {ok:false, error:"authorize
2483+
declined", decidedBy:"timeout", decidedAt}` — and audited as
2484+
`auth.unreachable` with `reason:"no_connected_client"`, so an operator can
2485+
tell "nobody was there" from "somebody said no". Before this the same call
2486+
held the socket open for the full five minutes with no status and no body,
2487+
which is indistinguishable from a hang; in practice that is what
2488+
`curl -X DELETE /api/sessions/<id>` from a script saw, because a request
2489+
without `?cid=` has no client to ask.
2490+
2491+
A bus that cannot answer the question (a test double that does not model
2492+
the connection registry) is treated as "might have a listener" and keeps
2493+
the old wait. The short-circuit can only ever deny — no path approves
2494+
anything without a recorded decision.
2495+
24482496
## Blocking prompts: what each one can actually answer
24492497

24502498
`components/modals.tsx` renders three blocking prompts. Two of them carry a

‎docs/webui.zh-CN.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1604,6 +1604,7 @@ composer 实际调用的那个函数。
16041604
| 种类 | 谁写的 | 在引擎 runtime DB 里? | 轮询后还在吗? |
16051605
| --- | --- | --- | --- |
16061606
| 引擎回合(`› ping`、`● pong`、工具块) | 引擎,流式写进 `cs.chat` | 在 | 在,并从 DB 刷新 |
1607+
| 该回合的流式**镜像**(压平成一行的 `● 答案…`、缺参数的 `→ bash` 头) | 同一条流,写进同一个数组 | 在——同样的文本,只是被折叠了 | **不在——它会退役**,由引擎自己的行顶替 |
16071608
| `/api/cmd` 回显(`› /help`、`● 可用命令:…`、`● 当前 model=…`、`● 变更概览 …`) | `interaction/commands.js` 写进 `cs.chat` | **不在——引擎从没见过它** | 在,而且只有它能让它留下 |
16081609
| 别的客户端跑的回合(桌面版、TUI) | 引擎,属于另一个 cid | 在 | 在,会被拉进来——这正是轮询的目的 |
16091610

@@ -1613,6 +1614,24 @@ composer 实际调用的那个函数。
16131614
(`lib/transcript.js`)把引擎读到的内容与已展示的行并行走一遍,引擎不
16141615
知道的行原地保留,引擎多出来的部分追加到末尾。
16151616

1617+
镜像是第三类,也正是合并必须让它退役的那一类。回合流式进行时,同一份引擎
1618+
输出还会以折叠形态第二次写进 `cs.chat`——答案与思维分支只写一行
1619+
(`前缀 + text.replace(/\n+/g, " ").trim()`),而引擎自己的映射器是源文本
1620+
一行一个数组项;帧里没带 `rawInput` 时工具头写成 `→ bash`,引擎那边却是
1621+
`→ bash {"command":…}`。两者永远不可能逐字节相等,于是并行走查把每一个
1622+
镜像都判成「本端自己写的行」留下,再把引擎整条脊柱追加在它后面:答案、工具
1623+
块、思维链各渲染两份,而 `persistCurrentChat` 把这份重复一并固化。UAT 实测:
1624+
落盘 81 行对应引擎 67 行读数,其中 14 行是引擎内容的第二份副本。
1625+
1626+
退役判据是同一性检验,不是形状启发式。压平的散文行镜像
1627+
(`●`/`▲`/`›`/`○`)当且仅当从游标处起、同一字形的引擎行极大连续段折叠后
1628+
(文本以单空格相接、所有空白游程压成一个空格)恰好等于镜像折叠后的文本时
1629+
退役。工具头当且仅当游标处的引擎行是同名工具时退役,两侧缩进的块体一并
1630+
带走。因此引擎必须已经在那个位置上持有同样的文本:`/api/cmd` 回显引擎从
1631+
没见过,没有可折叠的对象,保留。判据落空时——异常空格、引擎尚未写完的
1632+
工具块——该行原地保留,也就是合并原有的双份渲染。没有任何一条路径会丢掉
1633+
引擎读数没有交代的内容。
1634+
16161635
服务端写的注解行——`§§ processed_duration=Nms`、`§§ turn_msg=<id>`、
16171636
`##tc:<id>`——是唯一不能按普通行处理的引擎行:位置就是它们的全部意义,
16181637
解码器把每一行解析到它上方的块上。聊天记录早于某个标记落盘的标签页手里
@@ -1828,6 +1847,21 @@ Hono 仍然为所有实际请求持有 `/api/health` 与 `/api/settings`;旧
18281847

18291848
白名单是唯一可信源 —— 不在列表里的无法走模态门禁。
18301849

1850+
**5 分钟预算回答的是"人看到了弹窗但没点",不是"根本没有人看到过弹窗"。**
1851+
门禁是一次推送:`pushAuthRequest` 把 `needs_authorization` 帧写进发起方标签页的
1852+
SSE 响应,裁决再经 `POST /api/auth/decision` 从另一条连接回来。当该请求所属的
1853+
客户端没有活连接时(`state-bus.js#hasDecisionListener` —— 该 cid 没有登记响应,
1854+
或登记的响应已不可写),没有人能裁决,失败即关闭的结果在那一刻就已确定。请求
1855+
立刻以既有的拒绝体作答 —— `403 {ok:false, error:"authorize declined",
1856+
decidedBy:"timeout", decidedAt}` —— 并记 `auth.unreachable` 审计(带
1857+
`reason:"no_connected_client"`),让运维能区分"根本没人"和"有人拒绝了"。在此
1858+
之前,同一个调用会把 socket 满开五分钟、不给状态码也不给响应体,与挂起无法区分;
1859+
实际表现就是脚本里的 `curl -X DELETE /api/sessions/<id>` —— 不带 `?cid=` 的请求
1860+
压根没有可询问的客户端。
1861+
1862+
若总线无法回答这个问题(不建模连接注册表的测试替身),按"可能有人"处理,保持原
1863+
有的等待语义。这条短路只可能拒绝,不存在任何"未经记录裁决即放行"的路径。
1864+
18311865
## 阻断式弹窗:各自到底能应答什么
18321866

18331867
`components/modals.tsx` 渲染三个阻断式弹窗。其中两个的决定引擎收得到,另一个收不到 —— 那个不装样子,而是直说。这个区分是契约,不是界面偏好:**一个把决定发往引擎从不读取之处的按钮,会让点击"成功"而提问一直挂着**,比干脆不显示该按钮更糟。

‎packages/webui/server/engine/index.js‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@
3636
// catalogue host itself is now reached through this facade too
3737
// (engine/host.js), so the plugins and turn-diff routes no longer name
3838
// lib/acp-client.js. M3 batches B1 (#9 #10 #72 #74 #75), B2 (#8 #11),
39-
// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6), B6 (#3) and
40-
// B7 (#13 #69 #70 #71) done. The rest of M3, then M4, will route their
41-
// consumers through this facade one endpoint family at a time.
39+
// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6), B6 (#3),
40+
// B7 (#13 #69 #70 #71), B8a (#12's pure layer + gate) and B8b (#12's
41+
// runner + route branch) done. The rest of M3, then M4, will route
42+
// their consumers through this facade one endpoint family at a time.
4243

4344
import { ENGINE_CAPABILITY_KEYS } from "./capabilities.js";
4445
// Declarations only — importing the provider *host-construction* modules
@@ -260,6 +261,42 @@ export {
260261
resolveSwitchWorkspace,
261262
selectTranscriptBackfill,
262263
} from "./session-switch.js";
264+
// The STREAMING SEND family (step M3, batches B8a and B8b): #12
265+
// POST /api/send. Same cycle, same TDZ rule, same reasoning:
266+
// streaming-send.js's `STREAMING_SEND_ENDPOINTS` table is a literal and
267+
// every binding it needs (`getEngineProvider`,
268+
// `DEFAULT_ENGINE_PROVIDER_ID`) is read inside a function body, so a
269+
// cold `import("./engine/index.js")` can never hit a temporal dead
270+
// zone. Its static imports are `engine/capabilities.js`,
271+
// `engine/index.js` and the node builtins; the host getter, the
272+
// per-turn host wrapper and the attachments helper are reached through
273+
// `await import()` inside the data plane, which is what keeps an
274+
// acp-only server off the runtime graph.
275+
//
276+
// It gates HARD, the first M3 family to do so, and the reason is
277+
// structural rather than a policy preference: #12's response is
278+
// `{ok:true}` written BEFORE the engine is called, so a provider with
279+
// no send surface could only be answered with an ack for a turn that
280+
// never runs. See that module's header for the full argument, for the
281+
// two of the three red lines it owns, and for the eight recorded
282+
// debts.
283+
export {
284+
SEND_EVENT_KINDS,
285+
STREAMING_SEND_ENDPOINTS,
286+
assertStreamingSendCapability,
287+
checkStreamingSendCapability,
288+
classifySendEvent,
289+
openEngineSendStream,
290+
projectSendAttachments,
291+
resolveStreamingSendProvider,
292+
rewriteDrainedAnswerLine,
293+
sendSegmentAdvance,
294+
sendStillViewing,
295+
sendTerminalOutcome,
296+
sendToolHeaderLine,
297+
sendToolUpdate,
298+
sendUsageTotals,
299+
} from "./streaming-send.js";
263300
export { LOCAL_RUNTIME_V2_CAPABILITIES } from "./providers/local-runtime-v2.capabilities.js";
264301
export { TUI_RUNTIME_ADAPTER_CAPABILITIES } from "./providers/tui-runtime-adapter.js";
265302
// The INTERRUPT family (step M3, batch B7): #13 POST /api/stop, #69

0 commit comments

Comments
 (0)