diff --git a/.gitleaks.toml b/.gitleaks.toml index e1a9fe8dd..1ce56bec4 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -118,9 +118,10 @@ regexTarget = "match" regexes = ['''^[A-Za-z_$][A-Za-z0-9_$]*\.setRsaPrivateKey = [A-Za-z_$][A-Za-z0-9_$]*\.rsa\.setPrivateKey ?$''', '''^[A-Za-z_$][A-Za-z0-9_$]*\.privateKeyToAsn1 = [A-Za-z_$][A-Za-z0-9_$]*\.privateKeyToRSAPrivateKey ?$''', '''^[A-Za-z_$][A-Za-z0-9_$]*\.generateKey = [A-Za-z_$][A-Za-z0-9_$]*\.pbe\.generatePkcs12Key;?$'''] [[rules.allowlists]] -description = "Leak-prevention tripwire fixture: asserts the facade never serializes this fake key" -condition = "AND" +description = "Leak-prevention tripwire fixture: asserts the facade never serializes fake provider keys" paths = ['''(^|/)packages/webui/test/lib/engine/model-reads\.test\.js$'''] -regexTarget = "match" -regexes = ['''apiKey: "sk-secret-should-never-leak"'''] + +[[rules.allowlists]] +description = "Credential-guard fixture: fake canary key material asserts the 403 guard" +paths = ['''(^|/)packages/webui/test/routes/fs-credential-guard\.test\.js$'''] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..875e238d2 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,7 @@ +# gitleaks fingerprints for deliberate fake-credential fixtures in tests. +# Both files exist to handle fake key material (a leak-prevention tripwire and +# a credential-guard canary); the flagged content is synthetic by construction. +985b78803621f04efa6498384319ed588b361dff:packages/webui/test/routes/fs-credential-guard.test.js:private-key:79 +985b78803621f04efa6498384319ed588b361dff:packages/webui/test/routes/fs-credential-guard.test.js:private-key:81 +985b78803621f04efa6498384319ed588b361dff:packages/webui/test/lib/engine/model-reads.test.js:generic-api-key:149 +985b78803621f04efa6498384319ed588b361dff:packages/webui/test/lib/engine/model-reads.test.js:generic-api-key:117 diff --git a/packages/webui/docs/ARCHITECTURE.md b/packages/webui/docs/ARCHITECTURE.md index bcd4a1593..92a68cb10 100644 --- a/packages/webui/docs/ARCHITECTURE.md +++ b/packages/webui/docs/ARCHITECTURE.md @@ -1014,11 +1014,11 @@ or webui's". For a write it is decided by **who owns the rows the write destroys** — and in this family that question does not have the same answer twice in a row. -| Endpoint | Capability · sub-item | Enforcement | Value source | -| --- | --- | --- | --- | -| `DELETE /api/sessions/:id` (#7) | `sessionCrud` · `deleteSession` | hard — 501 | the webui session store, the in-memory ACP session cache, the sidebar tree cache, and the engine's own `local_runtime_*` rows via `lib/mcode-session-delete.js` | -| `POST /api/sessions/rename` (#4) | none of the 14 keys | none — the gate is a reported no-op | webui's own session store, and nothing else. The engine's title is not written | -| `POST /api/sessions/cleanup-orphans` (#6) | `sessionCrud` · `deleteSession` | hard — 501 | the same store, plus each selected id delegated to #7, so it reaches the same engine rows | +| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source | +| --- | --- | --- | --- | --- | +| `DELETE /api/sessions/:id` (#7) | `engine/session-writes.js#planEngineSessionDelete` → `engine/session-writes.js#commitEngineSessionDelete` / `engine/session-writes.js#commitEngineOrphanSessionDelete` / `engine/session-writes.js#previewEngineSessionDelete` | `sessionCrud` · `deleteSession` | hard — 501 | the webui session store, the in-memory ACP session cache, the sidebar tree cache, and the engine's own `local_runtime_*` rows via `lib/mcode-session-delete.js#deleteMcodeSessionFromDb` | +| `POST /api/sessions/rename` (#4) | `engine/session-writes.js#applyEngineSessionRename` | none of the 14 keys | none — the gate is a reported no-op | webui's own session store, and nothing else. The engine's title is not written | +| `POST /api/sessions/cleanup-orphans` (#6) | `engine/session-writes.js#readOrphanSessionWriteIds`, then each selected id delegated to `engine/session-writes.js#commitEngineOrphanSessionDelete` | `sessionCrud` · `deleteSession` | hard — 501 | the same store, plus each selected id delegated to #7, so it reaches the same engine rows | **Why #7 and #6 gate hard.** Both destroy rows in the engine's own `local_runtime_*` tables, and there is no webui-side copy of a transcript @@ -1054,15 +1054,17 @@ returns, plus `enforcement`. **The plan/commit split, and why the route did not shrink to nothing.** #7 is exported as a pair rather than one `deleteSession(options)`: -1. `planEngineSessionDelete` resolves the id and runs the gate. It - mutates nothing, so it is safe to run *before* the user is asked - anything. +1. `engine/session-writes.js#planEngineSessionDelete` resolves the id and + runs the gate. It mutates nothing, so it is safe to run *before* the + user is asked anything. 2. `authorize()` and the write-ahead `session.delete.intent` audit happen **between** the plan and the commit. The intent line has to be durably recorded before any row is removed, and it records the match kind and chat length the plan produced. -3. `commitEngineSessionDelete` / `commitEngineOrphanSessionDelete` / - `previewEngineSessionDelete` perform the write and fan-out. +3. `engine/session-writes.js#commitEngineSessionDelete` / + `engine/session-writes.js#commitEngineOrphanSessionDelete` / + `engine/session-writes.js#previewEngineSessionDelete` perform the write + and fan-out. A facade that owned the whole operation would have had to swallow that ordering into a callback. The route keeps request parsing, the authorize @@ -1097,6 +1099,22 @@ of its own — the same split B3 drew for `lib/mavis-usage.js` and B4 for 32 entries exported from the lib module, and the facade contains no SQL verb at all. +**#6's response shape is this batch's byte-for-byte red line, so the +payload is built in the facade and never re-assembled in the route.** The +preview is four keys, in that order: `{ok, dryRun, count, ids}`; the +real path's no-op is `{ok, dryRun:false, deleted, ids}`. The file read +stays in the facade rather than the route because the rule and the bytes +it reads are one decision: a sweep that read a different file than the +one whose rule it applies would be a bug waiting for a config change. +The BOM strip is the store's own on-disk convention (written by an +editor, not by webui) and is preserved exactly; a parse failure answers +`[]`, which the pre-facade code did too, and a corrupt store must not +turn a cleanup request into a 500. `dryRun` suppresses the kill and the +cache drop, because a preview mutates nothing and a preview that shuts +down the user's ACP child is a side effect the `?dryRun=true` contract +does not include; the COUNT still runs, read-only, inside +`lib/mcode-session-delete.js`. + **Three things this batch records as known debt instead of deciding:** 1. The 32-table SQL is still in `lib/mcode-session-delete.js`, for the @@ -1115,6 +1133,327 @@ verb at all. defensible alternative and the choice is not the batch's to make. A test pins the semantics that exist so the behaviour is at least stated. +#### Which endpoints route through the facade (step M3, batch B6) + +`engine/session-switch.js` covers one endpoint, and it is the busiest +single endpoint in the migration: #3 answers a question whose failure +modes are all user-visible at once — a wrong answer loses the +conversation on screen, re-roots the file tree on the wrong project, or +resurrects the "extra untitled entry" sidebar confusion. The route kept +the id resolution, the overlay creation, the title lookup, the +transcript backfill, the workspace containment, the per-client state +mutation and the response body; it now keeps only request parsing, the +status write and the audit append. + +| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source | +| --- | --- | --- | --- | --- | +| `POST /api/sessions/switch` (#3) | `engine/session-switch.js#applyEngineSessionSwitch` | `sessionCrud` · `getSession` | soft — reports | webui's own `sessions.json` for the record, its title, its chat and its workspace; the engine touches are two enrichments — the walked-session cache title, and `transcript.js#loadTranscriptChatLines` | + +**Why #3 gates soft, when #7 and #11 gate hard.** The question is +"if a provider declares this capability absent, can the endpoint still +serve a truthful answer?" and for #3 the answer is yes. The payload's +primary data is webui's own store; both engine touches already have a +defined degradation — the title falls back to the cache and then to the +"Mcode session" placeholder, the transcript falls back to the stored +chat, and neither failure is visible as a failure. Gating hard would +**remove a working endpoint** in response to a declaration about a +capability it does not depend on, and would do so under exactly the +transport that has the most users. So `checkSessionSwitchCapability` +reports and never throws; the 501 machinery in `engine/errors.js` stays unused +by this family, and the suite pins that it stays unused. That is the +`engine/session-export.js` argument reused rather than re-argued — the #110 +fake-success discipline applied in the other direction, since a missing +enrichment must not be dressed up as a failure. + +**The backfill decision is a data decision, not a route decision.** +`engine/session-switch.js#selectTranscriptBackfill` is the whole rule, +and it has exactly three branches, each of which the operator log +distinguishes by name: + +| `reason` | Stored buffer | Action | +| --- | --- | --- | +| `empty` | no chat yet | read the engine transcript and re-persist — unchanged since the first version of this path, because a session that was never rendered must show its history rather than "No messages yet" | +| `stored_cumulative` | polluted by the segment-accumulator bug | prefer the engine read and re-persist. The original rule only backfilled an empty buffer, so a polluted buffer saved via `saveSessions` won forever | +| `stored_shrinks` | clean | keep the stored chat. transcript-sync overwrites the stored buffer from the engine within ~4s, so stored-only lines are lost regardless, and clobbering a clean buffer on **every** switch is the worse failure | + +The predicate behind the middle branch is +`engine/session-switch.js#chatLooksCumulative`: a cumulative buffer has +at least one later `●` line whose text is a strict superset of an +earlier one, because the accumulator never reset between segments. It is +O(n²) in the `●` line count, and that is affordable because a session's +`chat` is capped at ~400 lines. It is conservative on both sides: a +single-`●` buffer is never cumulative, non-`●` rows (system, tool, `▲` +thought) are ignored, and equal-length lines are a tie rather than a +superset. The third branch deliberately does **not** promise draft +preservation — the composer keeps its own draft in its own state. + +**The read must never break the switch.** +`engine/session-switch.js#readEngineSwitchTranscript` never throws. +Every failure path — missing db, unloadable `better-sqlite3`, schema +drift, a throwing probe — lands as `{ok: false, reason}` and the caller +keeps the stored chat, so a switch that 501s because an enrichment was +unavailable never becomes a dead endpoint. The `reason` strings are the +reader's own, forwarded verbatim, because the operator log that reports +them and the reader's own vocabulary are one contract. + +**The workspace write is a containment-gated side effect, and it runs +before any `cs` mutation.** The target's stored `workspace` is +historical input: it may name a directory the user has since removed +from the allowed roots. `engine/session-switch.js#resolveSwitchWorkspace` +resolves target-first and passes the candidate through the same +`workspace.js#assertWorkspacePath` that the workspace picker, +`handleNewSession` and the fs routes use. Two properties are +load-bearing. The switch **never** falls back to the workspace the user +is currently in — that is the reported "file tree still shows the +previous project" defect, and it is why `currentWs` is not a parameter +of that function. And a refused switch answers 400 +(`workspace_refused`, a third outcome next to `ok` and `not_found` +rather than an exception) with the client state left exactly as it was. +A new first-touch overlay is created with `workspace: ""`, so +target-first resolution lands on `DEFAULT_WORKSPACE` for it instead of +stamping it with whatever project the switch happened to start from. + +**Resolution order is `mvs_` first, and that is the single-base-session +rule.** `engine/session-switch.js#resolveSwitchTarget` matches the +engine session id before the webui uuid — the **opposite** of the write +family's `resolveSessionTarget`, and the difference is a product rule +rather than a style choice. A switch addressed by `mvs_` must land on +the record that *is* that engine session, because the endpoint's whole +point is one conversation with one identity; the delete and rename paths +are addressed by a user who already has the record in front of them and +look the uuid up first. A first-touch `mvs_` therefore creates exactly +one overlay record whose id **is** the `mvs_` sid, via +`sessions.js#ensureOverlayForMcodeSid`, and `matchKind` stays `null` so +the audit payload's `matchKind || "new_from_mcode"` fallback still +labels what operators read as an invented wrapper. The client-state +write (`cs.sessionId`, `cs.mcodeSessionId`, title, chat buffer, the +three cumulative usage counters, and a re-rooted `cs.workspace`) lives +next to `state-bus.js#runChatViewChat`, which is what puts the mid-run +mirror into the response payload. + +**`lastUsedWorkspace` is deliberately left alone.** Last-used is +written only by the send path, because switching is browsing. Pinning +the browsed workspace to the top of the sidebar is the reported "click +any session in C and C auto-sorts first" behaviour, and this batch keeps +it true rather than tidying it up. + +**Three things this batch records as known debt instead of deciding:** + +1. The **3-candidate transcript probe** is still here, and this batch is + the batch the plan named for retiring it. It could not be retired + here without breaking the batch's own red line, for four reasons: + (a) the default `acp` transport has **no engine surface** — + `cliService.getMessages` is reachable only through the v2 catalogue + host, which only the `runtime` transport boots, so deleting the + probe empties the backfill on the default transport and on half of + the two-transport test matrix; (b) the two reads **cap different + things** — the probe reads a whole session and caps the mapped + *lines* at 400 / 200KB, while `getMessages` paginates and caps + *messages*, and the two are interchangeable only after proving that a + bounded message page's tail yields the same 400 lines; (c) the + **ordering is not the same ordering** — the probe orders + `created_at_ms ASC, rowid ASC` and `getMessages` orders by + `MessageQueryService`'s own key, so ties disagree, and a transcript + whose order flips is a transcript the user reads wrong; and (d) + **export still owns the legacy candidates** — B2 left + `GET /api/sessions/:id/export` on the legacy-only probe set on + purpose, because its `mcode_unavailable` shape is byte-pinned by + existing tests against exactly those three candidates, and widening + export's set would turn its enrichment from "unavailable" into + "answering", which is a product change rather than a migration step. + What this batch *did* collect is the coupling that made the probe + look unremovable: `routes/sessions.js` no longer names + `transcript.js` at all, the read has one seam, and the candidate list + is now an engine-layer implementation detail instead of something two + routes import. The remaining work is a seam swap that belongs to + **M4-1** — the batch that registers an ACP provider and therefore + makes an engine surface reachable under the default transport — and + it should land together with an equivalence test against a live v2 + host and with export's probe set widened in the same commit. +2. The **first-touch overlay is still a webui-side write**. A bare + `mvs_` switch creates a record in `sessions.json` that the engine + knows nothing about, so the engine's session list and webui's wrapper + list are two different questions that happen to agree. Pre-existing, + unchanged here; closing it means deciding who owns session identity. +3. The **usage sync is not gated**. `applyMavisUsageToCs` reads webui's + own mavis tables, so it declares no capability and its failure is + still swallowed with a debug-only warning. That asymmetry — identity + and transcript are degraded, usage is dropped silently — predates + this batch. Naming `usageStats` would gate a working endpoint on a + capability whose absence changes nothing visible; the real question + is whether a silent drop is the right product behaviour, and that is + not this batch's to decide. + +#### Which endpoints route through the facade (step M3, batch B7) + +Batch B7 contributes four endpoints across two modules, and the four +have **two** gate policies between them — which is the first batch whose +answer to the gate question is not uniform within its own family. The +split is a fact about the endpoints, not a compromise between opinions. + +| Endpoint | Facade function | Capability · sub-item | Enforcement | Value source | +| --- | --- | --- | --- | --- | +| `POST /api/stop` (#13) | `engine/interrupt.js#applyEngineStop` | `interrupt` · `abortSession` | soft — reports | `mcode-rpc.js#cancelSession` (a notification) plus `state-bus.js#getActiveChild` and the child-process kill — webui's own process management, which consults no provider | +| `POST /api/protocol/cancel` (#69) | `engine/interrupt.js#sendEngineSessionCancel` | `interrupt` · `abortSession` | soft — reports | the same notification alone; the refusal shape is the endpoint's own truthful "I could not deliver it" answer | +| `POST /api/protocol/load-session` (#70) | `engine/session-load.js#loadEngineSession` | `sessionCrud` · `loadSession` | **hard — 501** | `mcode-rpc.js#loadSession`; the sidebar entry is a webui-side write that is only allowed *after* the engine answers | +| `POST /api/protocol/activate-session` (#71) | `engine/session-load.js#activateEngineSession` | `sessionCrud` · `activateSession` | soft — reports | `mcode-rpc.js#activateSession`, then the client-state rebind | + +**`cancelled` does not mean "the prompt stopped".** `session/cancel` is +a **notification**: the engine registers it with `app.onNotification` +and aborts the active prompt's `AbortController`, so a request would +come back "Method not found". A notification carries no reply, which +means a success here means "sent" — the response field is `cancelled` +for historical reasons. #13 and #69 answer that differently on purpose +and both differences are pinned by the suite: #13 pairs `cancelled:true` +with `hardKilled:false` and never escalates, while #69 pairs a refusal +with a pointer to the endpoint that can. + +**`hardKilled` is a report about the first decision, not about the +process.** It is true exactly when a child was registered **and** the +gentle path did not take (`child && !cancelled`) — i.e. webui called +`child.kill()` on its way out of the handler. It is written into the +response body before the bounded escalation timer can possibly fire, so +`hardKilled:true` never certifies that anything is dead. The same +asymmetry is why the `note` string says "hard kill (session/cancel +could not be delivered)" even when no kill ran at all: the note names +*why* the gentle path did not happen, not what followed. Both wordings +are load-bearing and both are pinned. + +**The escalation is bounded, and the bound is part of the contract.** +`engine/interrupt.js#STOP_FORCE_KILL_MS` is 5000 ms, and it is exported +because it is a contract value rather than an implementation detail: the +window is what makes "已停止" mean "已停止". The file this batch +migrated ran 2000 ms; the batch plan transcribed the bound as "abort +5s" and the product call (2026-10-03) took the plan's value, accepting +that a stubborn child gets three extra seconds to finalize at the cost +of "already stopped" being a lie for three extra seconds. Two guards on +the timer are load-bearing. It is `unref()`ed, so an unexpired stop +timer can never hold the process open. And it re-checks the **cached** +raw `child_process` handle captured *before* the timer was armed — +`child.child` may be nulled by the runner's own `stop()` in the +meantime, and a nulled handle read at fire time would silently skip the +very escalation the cascade exists for. + +**Two scoping rules make the cascade act on the right turn.** The child +lookup is narrowed to `(cid, cs.mcodeSessionId)` — the **viewed** +session's child, not "any child of this tab", because a tab may run two +conversations at once and stopping must not signal the other turn's +subprocess. And the zombie-claim reset is a *decision* in the engine +layer, not a mutation: `engine/interrupt.js#stopLeftStaleClaim` answers +`claimStale`, and the route performs `resetThinkingClaim` only when it +is true, because that helper is shared with `handleSend` and moving it +would have been a second, unrelated change to the send route. + +**Why the interrupt family gates soft.** #13's escalation is webui's own +child-process management — the child was registered on webui's state bus +by webui's own runner, and killing it consults no provider. Hard-gating +#13 would delete the user's only way out of a stuck 思考中 panel in +order to express a doubt about the *gentle half* of a two-mechanism +endpoint. #69 already has a truthful "I could not do it" answer, and it +is its documented contract: 200 +`{ok:true, cancelled:false, warning, code, killEndpoint}`; a provider +with no interrupt surface produces exactly that shape, so a hard gate +would replace an accurate 200 with a 501 and teach the frontend a shape +it does not have today. + +**#70 is the only hard gate in this batch, and the invariant behind it +is an ordering.** The sidebar entry `createWebuiEntry` adds to +`sessions.json` must be **downstream of the engine's answer, never a peer +of it**. A provider that cannot load must not be able to leave a sidebar +entry pointing at a session the engine never opened, and a failed load +must not leave one either — a 200 carrying an entry and no session is +precisely the fake-success failure #110 exists to prevent. So +`engine/session-load.js#assertSessionLoadCapability` throws, the 501 +machinery in `engine/errors.js` is genuinely in use for this endpoint, and the +router's existing central mapping answers it — no route has to remember +to catch it. The entry itself is idempotent **on the `mcodeSessionId` +match, not on the caller**: a second call for a session webui already +wraps returns the existing record without re-saving, so repeated calls +cannot grow duplicate sidebar entries for one conversation. + +**#71 gates soft because hard-gating it would *be* the decision a human +has not made yet.** One ACP client tracks a single active session, so +"activate another" is how the client is re-pointed; the in-process host +has no single-active-session concept at all; and the plan gives the +endpoint's fate as an either/or — "语义塌缩(cs 切换 + resume), 或 +501". Those are two different products, and choosing the 501 branch +here would be choosing it silently, by a capability table, with no +changelog and no frontend work. So `checkSessionActivateCapability` +reports, and the route keeps the pre-M3 shape and status mapping byte +for byte. The endpoint's *meaning* is the order `cs.mcodeSessionId = +sessionId` first and `sessions.js#resetContext` second; reversing the +two leaves the context panel describing the session the user just left. + +**The three status mappings are separate tables, and the differences are +pinned.** `engine/session-load.js#loadFailureStatus` answers 503 for +`no_client`, 404 for a not-found/invalid code and **500** for +`unsupported` — deliberately *not* the 501 that `set-mode` answers for +the same code, because that asymmetry is the pre-existing contract. +`engine/session-load.js#activateFailureStatus` is the same table plus +an `unsupported → 501` row, which is again the pre-M3 mapping. And +`loadFailureWireCode` rewrites a "Resource not found" answer to +`session_not_found`, because `-32004` and `resource_not_found` do not +read as a session problem to a frontend; an undefined code stays +undefined so `JSON.stringify` drops the key exactly as before. + +**One module, two gate functions, rather than two modules.** B2 split +`engine/session-tree-reads.js` from `engine/session-export.js` because those two +endpoints declare *different* capabilities and their gate mechanics +differ for unrelated reasons. Here both endpoints share one capability, +one store, one client state and one route module, and the mechanics are +the two functions every other family already uses; splitting would +duplicate the transport table, the resolver and the two status mappers +to preserve a distinction that is one `enforcement` field wide — the +same shape B5's mixed `engine/session-writes.js` table already carries. + +**Three things this batch records as known debt instead of deciding:** + +1. **#71's semantic collapse is undecided, and this batch's only move + was to not decide it.** Both branches are costed in the module + header. Collapsing #71 into "switch + resume" is very nearly the + composition of #3 and #70, but the cost is the *response shape*: + today's body is `{ok, activeSessionId, data}` where `data` is the + engine's raw reply, and a collapsed endpoint has no such reply to + forward, so it would have to grow B6's byte-pinned switch payload or + invent a new one — changing the frontend, the docs and both language + versions at once. It would also change the endpoint's meaning, since + "activate" today mutates nothing beyond the two lines above while + "switch" re-roots the workspace, the chat buffer and the context + counters; a frontend that keeps calling it as activate would suddenly + get a workspace change. The 501 branch is cheap to build — the hard + gate already exists in this very file for #70 — but it is a + user-visible behaviour change that needs the UI degradation (hide or + disable the entry point, not an error toast) and it would fire for + every provider without single-active-session semantics, which per + the plan is the in-process host the default runtime transport is + built on. The tie-breaker is product knowledge this batch does not + have: who calls #71, and what they expect to happen to the sidebar, + the chat buffer and the workspace when it returns 200. +2. **#13 kills a running turn without asking whether it may.** The + cascade runs on the viewed session's child without checking whether + that child belongs to a turn the user still wants. That is the + pre-facade behaviour and arguably the right one (the user pressed + stop), but "refuse to stop a turn that has not yet produced output" + and "escalate only after a second attempt" are both defensible + alternatives. The same shape is recorded in B5's known debt for the + delete family, where the mirror-image question is "refuse to delete a + running session" — between them they are one policy question about + running sessions that deserves one decision rather than two. +3. **#70's 501 is the gate's 501, not the route's.** `loadSession` + answers 500 for `code === "unsupported"`, while a provider that + declares `sessionCrud.loadSession` absent answers 501 with + `engineCapabilityHttpResponse`'s body. Two different 501s can reach + this one route and only the second has ever existed; the router's + central mapping is what keeps them from being confused for each + other. Worth confirming against the frontend before M4 registers a + provider that can trip it. A fourth, narrower item: the "Resource not + found" rewrite only matches the string form, so a numeric JSON-RPC + code would reach the frontend verbatim behind a 500 — both behaviours + are pinned as-is, because widening the regex changes a wire shape and + the wider question (normalise once in `mcode-rpc.js` for every + caller) is a change to the RPC wrapper's contract, not to this + endpoint. + ## 6. Frontend topology ``` diff --git a/packages/webui/docs/ARCHITECTURE.zh-CN.md b/packages/webui/docs/ARCHITECTURE.zh-CN.md index 602424587..e37bc45d3 100644 --- a/packages/webui/docs/ARCHITECTURE.zh-CN.md +++ b/packages/webui/docs/ARCHITECTURE.zh-CN.md @@ -904,6 +904,316 @@ webui 将每个事件视为幂等更新;重放同一 (SSE 在断连时丢弃 → 不重试),客户端通过在 重连时拉取 `/api/state` 来应对。 +#### 哪些端点走门面写(迁移步 M3 批次 B5) + +批次 B5 是整场迁移里第一个端点会**销毁**数据而非读取数据的族,而这改变了门控问题 +所问的东西。对读来说,硬门控还是软门控取决于「这份数据是引擎的还是 webui 的」; +对写来说,取决于**这次写所销毁的那些行归谁所有**——而在这个族里,这个问题的答案 +没有连续两次是一样的。 + +| 端点 | 门面函数 | 能力 · 子项 | 强制方式 | 取值来源 | +| --- | --- | --- | --- | --- | +| `DELETE /api/sessions/:id`(#7) | `engine/session-writes.js#planEngineSessionDelete` → `engine/session-writes.js#commitEngineSessionDelete` / `engine/session-writes.js#commitEngineOrphanSessionDelete` / `engine/session-writes.js#previewEngineSessionDelete` | `sessionCrud` · `deleteSession` | 硬——501 | webui 的会话存储、内存中的 ACP 会话缓存、侧栏树缓存,以及经 `lib/mcode-session-delete.js#deleteMcodeSessionFromDb` 触达的引擎自己的 `local_runtime_*` 行 | +| `POST /api/sessions/rename`(#4) | `engine/session-writes.js#applyEngineSessionRename` | 14 个键里的任何一个都不适用 | 不门控——门控是「被报告的空操作」 | 只有 webui 自己的会话存储。引擎的标题**不**被写入 | +| `POST /api/sessions/cleanup-orphans`(#6) | `engine/session-writes.js#readOrphanSessionWriteIds`,随后逐个委派给 `engine/session-writes.js#commitEngineOrphanSessionDelete` | `sessionCrud` · `deleteSession` | 硬——501 | 同一份存储,加上每个被选中的 id 都走 #7 的真实删除分支,因此抵达同一批引擎行 | + +**为什么 #7 与 #6 硬门控。** 两者都销毁引擎自己 `local_runtime_*` 表里的行, +而不存在一份能存活下来的 webui 侧转录副本:那些行一旦没了,对话就没了。一个 +声明自己没有会话删除能力的 provider,确实无法让这两个端点给出诚实的答案, +所以 501 才是诚实的那个。#6 刻意声明与 #7 **相同**的一对能力·子项——这次清扫选出的是 +webui 侧的孤儿记录,但每个被选中的 id 都走 #7 的真实删除分支,而一条带着 +`mcodeSessionId` 的记录会连同它的引擎行一起被带走。给这次清扫软门控,等于让一个 +删不掉引擎会话的 provider 走后门抵达那些表,而且还会产生一种比 501 更糟的故障: +一次已授权的破坏性清扫写下了它的意图审计事件,然后让每一条委派删除全部失败。 + +**为什么 #4 什么都不声明。** 改名把 `title` / `titleCustom` / `updatedAt` 写进 +webui 自己的存储,完全不触碰任何引擎面。它唯一一次接触引擎是 +`lib/session-tree.js#invalidateSessionTree()`——一次缓存丢弃,那是侧栏从引擎投影 +标题这件事的读侧后果,而那个投影是 B2 的 `GET /api/session-tree`,它有自己的门控。 +在这里给出一个能力名,正是 B3 为 `GET /api/usage/forecast` 拒绝过的那类谎言: +拿一份它并不依赖的东西的声明,去门控一个能用的端点。 + +这一族在一处刻意偏离了它的同族:`SESSION_WRITE_ENDPOINTS` 的每一行都带同样的 +三个键——`capability`、`subItem`、`enforcement`——**包括那个没有能力的那一行**。 +B3 把「无引擎面」表达成表里的一个 `null` 条目;这里三个端点里有**两个**确实跨越了 +这条缝,于是夹在表中间的一个 `null` 空洞读起来像「还没填」而不像一个决定。 +门控**描述符**保留每一族都返回的六个字段,再加上 `enforcement`。 + +**plan/commit 的拆分,以及路由为什么没有缩成空壳。** #7 被导出为一对,而不是 +一个 `deleteSession(options)`: + +1. `engine/session-writes.js#planEngineSessionDelete` 解析 id 并跑门控。它不改写 + 任何东西,因此可以在**向用户问任何问题之前**安全地运行。 +2. `authorize()` 与写前(write-ahead)审计 `session.delete.intent` 发生在 plan 与 + commit **之间**。这条意图行必须在移除任何行之前被持久记录下来,而它记录的正是 + plan 产出的匹配种类与 chat 长度。 +3. `engine/session-writes.js#commitEngineSessionDelete` / + `engine/session-writes.js#commitEngineOrphanSessionDelete` / + `engine/session-writes.js#previewEngineSessionDelete` 执行写入与扇出。 + +一个把这整个操作都据为己有的门面,会不得不把那个顺序吞进一个回调里。路由保留 +请求解析、authorize 弹窗、审计顺序与每一个状态码;门面保留编排、门控与响应体。 + +**commit 内部的顺序就是那个特性,而且它是作为序列被断言的。** +`test/lib/engine/session-writes.test.js` 记录每一次改写并断言它们的顺序,因为 +一个只断言终态的测试看不见一具复活的会话: + +``` +invalidate-tree → kill-acp-child → drop-cache: → sql: → push: +``` + +树缓存在引擎写入**之前**被丢弃,好让一次并发读无法从删除前的数据库里把它重新填满。 +ACP 子进程在行被移除**之前**被停掉,因为它在内存里持有那个会话,并会在它的下一次 +请求里重写自己的注册行——那就是「已删除的会话又冒出来」这个 bug。离开缓存的只有 +**那一个**被删的 sid:把整份缓存作废会清空侧栏、再把它填满,读到用户那里就像删除失败。 + +**32 张表的 SQL 没有被搬走,这是被记录下来而不是被悄悄丢掉的。** 本批的批次计划给 +`lib/mcode-session-delete.js` 批注了「delete」。它被保留,是因为 +`lib/acp-client.js` 从它那里导入,而有四个测试文件绑定在那个导出名上;收集它意味着 +先搬走那些。门面通过 `await import()` 触达它,自己不发任何 SQL——这正是 B3 为 +`lib/mavis-usage.js`、B4 为 `lib/mcode-rpc.js` 划下的同一条分界。一个测试同时断言 +两半:表清单仍然是从那个 lib 模块导出的 32 条,而门面里一个 SQL 动词都没有。 + +**#6 的响应形状是本批逐字节的红线,因此载荷在门面里组装、绝不在路由里重装。** +预览是四个键、且就是这个顺序的 `{ok, dryRun, count, ids}`;而真实路径的空操作是 +`{ok, dryRun:false, deleted, ids}`。对应的文件读取也留在门面里而不是路由里,因为 +规则与它所读的字节是同一个决策:一次读取了与「它所应用的规则」不是同一个文件的 +清扫,是一次等在某次配置改动上引爆的 bug。BOM 剥离是存储自己在盘上的约定 +(由编辑器而非 webui 写入)并被原样保留;解析失败回答 `[]`——门面前代码也是这么做的, +而一份损坏的存储不得把一次清理请求变成 500。`dryRun` 会抑制子进程 kill 与缓存丢弃, +因为一次预览不改写任何东西,而一次关掉用户 ACP 子进程的预览是 `?dryRun=true` 契约 +并不包含的副作用;那条 COUNT 仍会跑,只读地跑在 `lib/mcode-session-delete.js` 里。 + +**本批记为已知债而不予决定的三件事:** + +1. 32 张表的 SQL 仍然在 `lib/mcode-session-delete.js` 里,理由见上文那些消费方。 +2. 改名只是**一个 webui 侧的标签**。`local_runtime_sessions` 里引擎自己的标题没有被 + 触碰,而侧栏树是从引擎读标题的。因此对一个由引擎支撑的会话,一次改名可能在包装 + 列表里看得见、在树里看不见。这是既有行为,本批没有改动它;关掉它意味着决定哪一份 + 存储对「展示用标题」是权威的,那是产品拍板。 +3. #7 不检测「这个会话此刻正在跑」。删除一个进行中的会话会从那个回合底下把 ACP + 子进程停掉,然后照常继续。那是门面前的行为,也可以说正是正确的行为(用户要求了), + 但「拒绝删除一个正在跑的会话」是站得住的替代方案,而这个选择不是本批该做的。 + 一个测试钉住了既有的语义,好让这个行为至少是被写下来的。 + +#### 哪些端点经由门面路由(迁移步 M3 批次 B6) + +`engine/session-switch.js` 覆盖一个端点,而它是整场迁移里最忙的单个端点: +#3 回答的那个问题,一旦答错,三种故障会同时被用户看见——屏幕上的对话丢掉、 +文件树被重新挂到别的项目上、或者侧栏那种「多出一条无名条目」的困惑重新出现。 +路由原本承担了 id 解析、覆盖记录创建、标题查询、转录回填、工作区包含性校验、 +逐客户端状态改写与响应体组装;现在它只保留请求解析、状态码写入与审计追加。 + +| 端点 | 门面函数 | 能力 · 子项 | 强制方式 | 取值来源 | +| --- | --- | --- | --- | --- | +| `POST /api/sessions/switch`(#3) | `engine/session-switch.js#applyEngineSessionSwitch` | `sessionCrud` · `getSession` | 软——只报告 | 记录本身、标题、chat 与工作区全部来自 webui 自己的 `sessions.json`;两处引擎接触都属于富化——已遍历会话缓存里的标题,以及 `transcript.js#loadTranscriptChatLines` | + +**为什么 #3 软门控,而 #7 与 #11 硬门控。** 判据是「若 provider 声明该能力缺失, +端点还能不能给出诚实的答案」,#3 的答案是能。载荷的主数据是 webui 自己的存储; +两处引擎接触都已有明确的降级路径——标题回落到缓存、再回落到「Mcode session」 +占位符,转录回落到已存的 chat,而任何一处失败都不会以失败的形式被看见。硬门控 +等于**拿一份它并不依赖的能力声明去删掉一个能用的端点**,而且恰好删在用户最多的 +那个传输上。所以 `checkSessionSwitchCapability` 只报告、从不抛错;`engine/errors.js` 里的 +501 机制在这一族保持未被使用,测试也钉住了它保持未使用这一条。这是 +`engine/session-export.js` 已经论证过、此处复用而非重证的理由——把 #110 的假成功纪律 +用在了相反方向:缺失的富化不得被包装成失败。 + +**回填决策是数据决策,不是路由决策。** +`engine/session-switch.js#selectTranscriptBackfill` 就是整条规则,它恰好有三个 +分支,每个分支在运维日志里都有各自的 `reason` 名称: + +| `reason` | 已存缓冲 | 动作 | +| --- | --- | --- | +| `empty` | 从未有过 chat | 读引擎转录并重新落盘——自这条路径的第一版起未变,因为从未渲染过的会话应当显示自己的历史,而不是「暂无消息」 | +| `stored_cumulative` | 被分段累加器缺陷污染 | 优先采用引擎读并重新落盘。原始规则只在缓冲为空时回填,因此经 `saveSessions` 落盘的污染缓冲会永远赢下去 | +| `stored_shrinks` | 干净 | 保留已存 chat。transcript-sync 会在约 4 秒内用引擎数据覆盖已存缓冲,因此仅存于 webui 的行无论如何都会丢,而在**每一次**切换上都覆盖一个干净缓冲是更糟的故障 | + +中间那个分支背后的判定式是 `engine/session-switch.js#chatLooksCumulative`: +被污染的缓冲至少存在一条 `●` 行,其文本是另一条更早 `●` 行的严格超集, +因为累加器在分段之间从未复位。它在 `●` 行数上是 O(n²),而这是可负担的, +因为单个会话的 `chat` 被上限截在约 400 行。它两侧都保守:只有一条 `●` 的缓冲 +不算被污染,非 `●` 行(system、tool、`▲` 思考)被忽略,等长的两行是并列而非超集。 +第三个分支刻意**不**承诺草稿保全——composer 把草稿存在它自己的状态里。 + +**这次读绝不能让切换失败。** `engine/session-switch.js#readEngineSwitchTranscript` +从不抛错。每一条失败路径——缺 db、`better-sqlite3` 装载不上、schema 漂移、探针 +抛错——都落成 `{ok: false, reason}`,调用方保留已存 chat,因此一次因为富化不可用 +而 501 的切换永远不会变成死端点。`reason` 字符串是读方自己的、原样透传, +因为报告它们的运维日志与读方自身的词汇表是一份契约。 + +**工作区写入是一个被包含性门控的副作用,并且跑在任何 `cs` 改写之前。** +目标的已存 `workspace` 是历史输入:它可能指向一个用户此后已从允许根目录中移除的 +目录。`engine/session-switch.js#resolveSwitchWorkspace` 先按目标解析,并把候选值 +交给工作区选择器、`handleNewSession` 与各 fs 路由共用的那道 +`workspace.js#assertWorkspacePath`。其中两条性质是承重的。切换**绝不**回落到 +用户当前所在的工作区——那正是「文件树仍显示上一个项目」这条已报缺陷, +也正因如此 `currentWs` 不是这个函数的参数。而被拒绝的切换回 400 +(`workspace_refused`,是 `ok` 与 `not_found` 之外的第三个取值而不是异常), +且客户端状态原封不动。新建的首次触达覆盖记录以 `workspace: ""` 创建, +因此按目标优先的解析会落到 `DEFAULT_WORKSPACE`,而不是把切换发起时所在的项目盖上去。 + +**解析顺序是 `mvs_` 优先,而这就是「单一基础会话身份」这条规则。** +`engine/session-switch.js#resolveSwitchTarget` 先匹配引擎会话 id,再匹配 webui 的 +uuid——与写族的 `resolveSessionTarget` **正好相反**,这个差异是产品规则而非风格选择。 +一个以 `mvs_` 发起的切换必须落在**就是**那个引擎会话的记录上,因为这个端点的全部 +意义就是「一段对话、一个身份」;而删除与改名路径由一个已经把记录摆在眼前的用户发起, +先查 uuid。首次触达的 `mvs_` 因此恰好创建一条覆盖记录,其 id **就是**该 `mvs_` sid, +经由 `sessions.js#ensureOverlayForMcodeSid`;`matchKind` 仍保持 `null`, +好让审计载荷里的 `matchKind || "new_from_mcode"` 兜底继续把运维眼中的 +「凭空造出的包装」标成那样。客户端状态改写(`cs.sessionId`、`cs.mcodeSessionId`、 +标题、chat 缓冲、三个按会话累加的用量计数器,以及重新生根的 `cs.workspace`) +与 `state-bus.js#runChatViewChat` 相邻,后者正是把运行中镜像放进响应载荷的那一步。 + +**`lastUsedWorkspace` 是刻意不动的。** 最近使用只由发送路径写入,因为切换属于浏览。 +把浏览过的工作区顶到侧栏最前,是「在 C 里点任意一条会话、C 就自动排到最前」这条 +已报行为,本批把它保持为真,而不是顺手「整理」掉。 + +**本批记为已知债而不予决定的三件事:** + +1. **三候选转录探针仍然存在**,而本批正是计划书点名要退役它的那一批。在此退役会 + 破坏本批自己的红线,理由有四条:(a) 默认 `acp` 传输**没有引擎面**—— + `cliService.getMessages` 只能经 v2 目录 host 触达,而只有 `runtime` 传输会去 + 启动它,所以删掉探针会让默认传输上的回填、以及本批门禁所依赖的两传输测试矩阵的 + 一半变成空转;(b) 两种读**截断的东西不同**——探针读整个会话并把映射后的*行*截在 + 400 行 / 200KB,而 `getMessages` 是分页的、截断的是*消息*,只有先证明一个有界 + 消息页的尾部能产出同样的 400 行,两者才可互换;(c) **排序不是同一种排序**——探针按 + `created_at_ms ASC, rowid ASC`,`getMessages` 按 `MessageQueryService` 自己的键, + 并列时二者会分歧,而顺序翻转的转录就是被用户读错的转录;(d) **导出仍独占旧候选集** + ——B2 刻意把 `GET /api/sessions/:id/export` 留在仅旧探针集上,因为它的 + `mcode_unavailable` 形状被既有测试按字节钉在那三个候选上,而扩大导出的候选集会让 + 它的富化从「不可用」变成「有答案」,那是产品变更而不是迁移步骤。本批**真正**收拢的 + 是让探针看起来无法移除的那层耦合:`routes/sessions.js` 已完全不再提及 + `transcript.js`,读只有一条缝,候选清单成了引擎层的实现细节,而不是两个路由各自 + 导入的东西。剩下的工作是**换缝**,属于 **M4-1**——注册 ACP provider、从而让引擎面在 + 默认传输下可触达的那一批——并且应当与一份针对真实 v2 host 的等价性测试、 + 以及同一提交里扩大的导出探针集一起落地。 +2. **首次触达的覆盖记录仍是 webui 侧写入。** 一次裸 `mvs_` 切换会在 `sessions.json` + 里创建一条引擎一无所知的记录,于是引擎的会话列表与 webui 的包装列表是两个恰好 + 答案相同的不同问题。这是既有行为、本批未动;关掉它意味着决定会话身份归谁所有。 +3. **用量同步不受门控。** `applyMavisUsageToCs` 读的是 webui 自己的 mavis 表, + 因此不声明任何能力,其失败仍被吞掉、只留一条 debug 级告警。这种不对称——身份与 + 转录会降级、用量被静默丢弃——早于本批存在。给这里补上 `usageStats` 会用一份 + 「缺失时用户看不见任何变化」的能力声明去门控一个能用的端点;真正的问题是静默丢弃 + 到底是不是正确的产品行为,而那不是本批该定的。 + +#### 哪些端点经由门面路由(迁移步 M3 批次 B7) + +批次 B7 用两个模块收了四个端点,而这四个端点之间只有**两种**门控策略——这是第一批 +族内答案并不统一的批次。这个分裂是端点的事实,不是两种意见之间的妥协。 + +| 端点 | 门面函数 | 能力 · 子项 | 强制方式 | 取值来源 | +| --- | --- | --- | --- | --- | +| `POST /api/stop`(#13) | `engine/interrupt.js#applyEngineStop` | `interrupt` · `abortSession` | 软——只报告 | `mcode-rpc.js#cancelSession`(一条通知)加上 `state-bus.js#getActiveChild` 与子进程 kill——webui 自己的进程管理,不咨询任何 provider | +| `POST /api/protocol/cancel`(#69) | `engine/interrupt.js#sendEngineSessionCancel` | `interrupt` · `abortSession` | 软——只报告 | 同一条通知本身;拒绝形状就是该端点自己那份诚实的「我没能送达」 | +| `POST /api/protocol/load-session`(#70) | `engine/session-load.js#loadEngineSession` | `sessionCrud` · `loadSession` | **硬——501** | `mcode-rpc.js#loadSession`;侧栏条目是 webui 侧写入,且只允许发生在引擎应答**之后** | +| `POST /api/protocol/activate-session`(#71) | `engine/session-load.js#activateEngineSession` | `sessionCrud` · `activateSession` | 软——只报告 | `mcode-rpc.js#activateSession`,随后是客户端状态重绑 | + +**`cancelled` 不等于「提示词已停」。** `session/cancel` 是一条**通知**:引擎用 +`app.onNotification` 注册它并中止当前提示词的 `AbortController`,所以若以请求形式 +发过去会得到「Method not found」。通知没有回包,因此这里的一次成功意味着 +「已发出」——响应字段之所以叫 `cancelled` 是历史原因。#13 与 #69 刻意用不同方式 +回答它,且两种差异都被测试钉住:#13 把 `cancelled:true` 与 `hardKilled:false` +配在一起且从不升级;而 #69 在拒绝时配上一个指向真正能升级的那个端点的指针。 + +**`hardKilled` 报告的是第一次决策,不是进程状态。** 它恰在「注册过子进程**且** +温和路径没走通」(即 `child && !cancelled`)时为真——也就是 webui 在离开处理函数 +的路上调用了 `child.kill()`。它被写进响应体的时刻早于有界升级定时器可能触发的 +时刻,因此 `hardKilled:true` 从不证明任何东西已经死掉。同样的不对称也是为什么 +`note` 字符串即使在根本没发生任何 kill 的情况下(没有子进程、没有 session id) +仍然写着「hard kill(session/cancel 无法送达)」:这条 note 说的是温和路径 +**为何**没有发生,而不是之后发生了什么。两处措辞都是承重的,也都被钉住。 + +**升级是有界的,而那个界本身就是契约的一部分。** +`engine/interrupt.js#STOP_FORCE_KILL_MS` 是 5000 毫秒,它被导出是因为它属于契约取值 +而不是实现细节:这个窗口才是让「已停止」真正等于「已停止」的东西。本批迁移过来的 +那个文件跑的是 2000 毫秒;批次计划把该界转写为「abort 5s」,产品拍板 +(2026-10-03)采纳了计划书的取值,等于接受顽固子进程多拿三秒去做收尾、代价是 +「已经停了」多撒谎三秒。定时器上有两条承重的守卫。它被 `unref()`,因此一个未到期的 +停止定时器永远不会把进程吊住。它重新检查的是**在装定时器之前就已捕获**的裸 +`child_process` 句柄——`child.child` 很可能在此期间被运行器自己的 `stop()` 置空, +而在触发时刻读到被置空的句柄,会静默地跳过这整条级联存在的意义。 + +**两条作用域规则让级联打在正确的那个回合上。** 子进程查找被收窄到 +`(cid, cs.mcodeSessionId)`——是**正在查看的**那个会话的子进程,不是「这个标签页的 +任意子进程」,因为一个标签页可能同时跑两段对话,停止不能去打断另一个回合的子进程。 +而僵尸声明的重置在引擎层是**决策**而非改写:`engine/interrupt.js#stopLeftStaleClaim` +给出 `claimStale`,由路由在它为真时才执行 `resetThinkingClaim`,因为那个辅助函数与 +`routes/chat.js#handleSend` 共用,把它搬走会是对发送路由的第二次、不相干的改动。 + +**为什么中断族软门控。** #13 的升级是 webui 自己的子进程管理——那个子进程是 +webui 自己的运行器注册到 webui 自己的状态总线上的,杀它不咨询任何 provider。 +给 #13 硬门控,等于为了表达对「两机制端点里**温和那一半**」的怀疑,删掉用户 +摆脱卡死「思考中」面板的唯一出路。#69 本来就有一个诚实的「我做不到」的答案, +而且那就是它的成文契约:200 `{ok:true, cancelled:false, warning, code, killEndpoint}`; +一个没有中断面的 provider 产出的正是这个形状,所以硬门控只会把一个准确的 200 +换成 501,并教前端一个它今天并不拥有的形状。 + +**#70 是本批唯一的硬门控,其背后的不变量是一个顺序。** `createWebuiEntry` 往 +`sessions.json` 里加的那条侧栏条目,必须**在引擎应答的下游,绝不是它的对等物**。 +一个不能装载的 provider 不得留下指向引擎从未打开过的会话的侧栏条目,一次失败的 +装载同样不得留下——一份带着条目却没有会话的 200,正是 #110 要防的假成功。所以 +`engine/session-load.js#assertSessionLoadCapability` 抛错,`engine/errors.js` 里的 501 机制 +在这个端点上确实被用上,答案由路由层既有的集中映射给出——没有任何路由需要记得去 +捕获它。条目本身的幂等性**取决于 `mcodeSessionId` 匹配,而不是取决于调用方**: +对 webui 已包装过的会话再次调用会直接返回既有记录而不重新落盘,因此重复调用无法 +为同一段对话长出重复的侧栏条目。 + +**#71 软门控,是因为给它硬门控本身**就是那个尚未由人做出的决定**。** 一个 ACP 客户端 +只跟踪一个活动会话,所以「激活另一个」正是客户端被重新指向的方式;进程内 host +根本没有「单活动会话」这个概念;而计划书把这个端点的归宿写成二选一—— +「语义塌缩(cs 切换 + resume),或 501」。这是两种不同的产品,在这里选 501 那一支 +就是由一张能力表静默地选掉它,既没有变更记录也没有前端工作。所以 +`checkSessionActivateCapability` 只报告,路由逐字节保持迁移前的形状与状态码映射。 +这个端点的**意义**就是顺序:先 `cs.mcodeSessionId = sessionId`,再 +`sessions.js#resetContext`;两者颠倒,会让上下文面板继续描述用户刚离开的那个会话。 + +**三张状态映射表是分开的,其差异都被钉住。** +`engine/session-load.js#loadFailureStatus` 对 `no_client` 回 503、对 not-found/invalid +类错误回 404、对 `unsupported` 回 **500**——刻意**不**用 `set-mode` 对同一 code 回的 +501,因为那种不对称就是既有契约。`engine/session-load.js#activateFailureStatus` 是 +同一张表再加一行 `unsupported → 501`,同样是迁移前的映射。而 `loadFailureWireCode` +把「Resource not found」改写成 `session_not_found`,因为 `-32004` 与 +`resource_not_found` 在前端看来都不像会话问题;未定义的 code 保持未定义, +于是 `JSON.stringify` 照旧丢弃这个键。 + +**一个模块、两个门控函数,而不是两个模块。** B2 拆出 +`engine/session-tree-reads.js` 与 `engine/session-export.js`,是因为那两个端点声明的是**不同**能力、 +且门控机制因无关理由而不同。这里两个端点共用一份能力声明、一份存储、一份客户端状态 +和一个路由模块,机制也就是其他各族已经在用的那两个函数;拆开会把传输表、解析器和 +两张状态映射表各复制一份,只为保住一个宽度仅一个 `enforcement` 字段的区分—— +这正是 B5 那张混合的 `engine/session-writes.js` 表已经承载的形状。 + +**本批记为已知债而不予决定的三件事:** + +1. **#71 的语义塌缩未决,而本批唯一的动作就是不去决定它。** 两个分支的代价都写在 + 模块头里。把 #71 塌缩成「switch + resume」几乎就是 #3 与 #70 的复合,但代价在 + *响应形状*上:今天的响应体是 `{ok, activeSessionId, data}`,其中 `data` 是引擎 + `session/activate` 的原始回包,而塌缩后的端点没有这样的回包可转发,它要么长成 + B6 那份按字节钉住的 switch 载荷、要么另造一份——两者都会同时改动前端、文档与 + 两个语言版本。它还会改变这个端点的**意义**:今天的「activate」除了上面那两行 + 之外不改 webui 的任何状态,而「switch」会重新生根工作区、chat 缓冲与上下文 + 计数器;一个继续按 activate 调用它的前端会突然得到一次工作区变更。501 那一支 + 造价很低——硬门控机制就在这个文件里、已经为 #70 建好——但它是一次用户可见的 + 行为变更,需要配套的 UI 降级(隐藏或禁用入口,而不是弹一个错误提示),而且它会 + 对**每一个**没有单活动会话语义的 provider 触发;按计划书,那恰恰就是默认 + runtime 传输所建立的那个进程内 host。打破平局需要的是本批并不具备的产品知识: + 谁在调 #71,以及当它返回 200 时,他们期望侧栏、chat 缓冲和工作区发生什么。 +2. **#13 不问一声就杀掉正在跑的回合。** 级联作用在正在查看的那个会话的子进程上, + 却不检查那个子进程是否属于用户仍想要的回合。那是门面前的行为,也可以说正是 + 正确的行为(用户按了停止),但「拒绝停止尚未产出任何输出的回合」与「第二次尝试 + 之后才升级」都是站得住的替代方案。同一形状也记在 B5 关于删除族的已知债里, + 那里镜像的问题是「拒绝删除一个正在跑的会话」——两者其实是同一个关于运行中会话 + 的策略问题,值得一次决定而不是两次。 +3. **#70 的 501 是门控的 501,不是路由的 501。** `loadSession` 对 + `code === "unsupported"` 回 500,而一个声明 `sessionCrud.loadSession` 缺失的 + provider 回的是 501 加 `engineCapabilityHttpResponse` 的响应体。两种不同的 501 + 都可能到达这一条路由,而只有后者曾经存在过;正是路由层的集中映射让二者不被 + 彼此混淆。在 M4 注册某个能触发它的 provider 之前,值得与前端确认一次。另有 + 一条更窄的:`Resource not found` 的改写只匹配字符串形式,因此一个数字型 JSON-RPC + code 会以 500 状态原样抵达前端——两种行为都按现状钉住,因为放宽正则会改动一份 + wire 形状,而更大的问题(是否在 `mcode-rpc.js` 里为所有调用方统一归一化)是 + 对 RPC 包装层契约的改动,不是对这个端点的改动。 + ## 6. 前端拓扑 ``` diff --git a/packages/webui/server/engine/index.js b/packages/webui/server/engine/index.js index 589a6ac91..d844df2e7 100644 --- a/packages/webui/server/engine/index.js +++ b/packages/webui/server/engine/index.js @@ -36,9 +36,9 @@ // catalogue host itself is now reached through this facade too // (engine/host.js), so the plugins and turn-diff routes no longer name // lib/acp-client.js. M3 batches B1 (#9 #10 #72 #74 #75), B2 (#8 #11), -// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6) and B6 (#3) -// done. The rest of M3, then M4, will route their consumers through -// this facade one endpoint family at a time. +// B3 (#15 #16 #17 #19), B4 (#20 #57 #73), B5 (#7 #4 #6), B6 (#3) and +// B7 (#13 #69 #70 #71) done. The rest of M3, then M4, will route their +// consumers through this facade one endpoint family at a time. import { ENGINE_CAPABILITY_KEYS } from "./capabilities.js"; // Declarations only — importing the provider *host-construction* modules @@ -262,6 +262,67 @@ export { } from "./session-switch.js"; export { LOCAL_RUNTIME_V2_CAPABILITIES } from "./providers/local-runtime-v2.capabilities.js"; export { TUI_RUNTIME_ADAPTER_CAPABILITIES } from "./providers/tui-runtime-adapter.js"; +// The INTERRUPT family (step M3, batch B7): #13 POST /api/stop, #69 +// POST /api/protocol/cancel. Same cycle, same TDZ rule, same reasoning +// as session-reads.js above: interrupt.js reads NOTHING from this module +// at module scope — its `INTERRUPT_ENDPOINTS` table is a literal and +// every binding it needs (`getEngineProvider`, +// `DEFAULT_ENGINE_PROVIDER_ID`) is read inside a function body. A new +// top-level `const X = SOMETHING_FROM_INDEX` in interrupt.js breaks the +// re-export exactly as it would in session-reads.js. Its ONLY static +// imports are `engine/index.js` and the node builtins; the state bus, +// the RPC wrapper and the config are reached through `await import()` +// inside the data-plane functions, which is what keeps the escalation +// timer and the kill cascade off the boot path. +// +// It gates SOFT for both endpoints, and the reason is endpoint-specific +// rather than family-wide: #13's escalation is webui's own +// child-process management and its zombie-claim reset is the user's +// only escape hatch from a stuck run, so hard-gating it would delete a +// working endpoint over a doubt about its GENTLE half; #69 already has +// a truthful "I could not deliver it" shape as its documented contract. +// The 501 machinery stays unused by this family, and the suite pins that. +export { + INTERRUPT_ENDPOINTS, + STOP_FORCE_KILL_MS, + applyEngineStop, + checkInterruptCapability, + resolveInterruptProvider, + sendEngineSessionCancel, + stopLeftStaleClaim, +} from "./interrupt.js"; +// The LOAD / ACTIVATE family (step M3, batch B7): #70 +// POST /api/protocol/load-session, #71 +// POST /api/protocol/activate-session. Same cycle, same TDZ rule, same +// reasoning: session-load.js's `SESSION_LOAD_ENDPOINTS` table is a +// literal and every binding it needs is read inside a function body; a +// new top-level `const X = SOMETHING_FROM_INDEX` there breaks this +// re-export exactly as it would anywhere else. Its only static imports +// are `engine/capabilities.js` and `engine/index.js`. +// +// This is the one M3 family that carries BOTH gate forms, and the split +// is a decision rather than an inconsistency: #70 gates HARD on +// `sessionCrud` · `loadSession`, because a "success" that skipped the +// engine would write a sidebar entry for a session the engine never +// loaded — the fake success the gate exists to prevent — while #71 gates +// SOFT, because hard-gating it would be silently answering the +// activate-semantic-collapse question the plan leaves open (semantic +// collapse vs 501). Both branches are costed in that module's KNOWN +// DEBT 1. Two functions, one family, one store, one route module: +// splitting it would duplicate the transport table, the resolver and +// the status mappers to preserve a distinction one `enforcement` field +// wide. +export { + SESSION_LOAD_ENDPOINTS, + activateEngineSession, + activateFailureStatus, + assertSessionLoadCapability, + checkSessionActivateCapability, + loadEngineSession, + loadFailureStatus, + loadFailureWireCode, + resolveSessionLoadProvider, +} from "./session-load.js"; /** * Registered providers. `transport` records which wire form the provider diff --git a/packages/webui/server/engine/interrupt.js b/packages/webui/server/engine/interrupt.js new file mode 100644 index 000000000..2dabc6bf6 --- /dev/null +++ b/packages/webui/server/engine/interrupt.js @@ -0,0 +1,509 @@ +// webui/server/engine/interrupt.js +// +// Migration step M3, batch B7 (part 1 of 2): the INTERRUPT family — +// +// #13 POST /api/stop — gentle cancel, then the kill cascade +// #69 POST /api/protocol/cancel — the gentle half, on its own +// +// What this file is for. Both endpoints end in a claim the user can act +// on — "your turn stopped", or "I could not stop it and here is the +// button that can" — and before M3 that claim was assembled in two +// routes, each of which reached into `lib/mcode-rpc.js#cancelSession` +// and `lib/state-bus.js#getActiveChild` directly. Three facts about +// that claim are load-bearing and none of them is visible from the +// route's edge any more: +// +// 1. `cancelled` DOES NOT MEAN "THE PROMPT STOPPED". `session/cancel` +// is a NOTIFICATION (mcode-rpc.js: the engine registers it with +// `app.onNotification`, which aborts the active prompt's +// AbortController; a request would come back "Method not found"). +// A notification carries no reply, so a success here means "SENT", +// and the word in the response is `cancelled` for historical +// reasons. The two endpoints answer that differently on purpose and +// both differences are pinned by the suite: #13 pairs `cancelled:true` +// with `hardKilled:false` and never escalates, while #69 pairs it +// with a pointer to the endpoint that CAN escalate. +// +// 2. `hardKilled` IS A REPORT ABOUT THE FIRST DECISION, NOT ABOUT THE +// PROCESS. It is true exactly when a child was registered AND the +// gentle path did not take (`child && !cancelled`) — i.e. webui +// called `child.kill()` on its way out of the handler. It is written +// into the response body BEFORE the bounded escalation timer can +// possibly fire, so `hardKilled:true` never certifies that anything +// is dead. The same asymmetry is why the `note` string says "hard +// kill (session/cancel could not be delivered)" even when NO kill +// ran at all (no child, no session id): the note names why the +// gentle path did not happen, not what followed. Both are load- +// bearing wording, and both are pinned. +// +// 3. THE ESCALATION IS BOUNDED, AND THE BOUND IS PART OF THE +// CONTRACT. If the gentle cancel does not take, a `setTimeout` at +// `STOP_FORCE_KILL_MS` re-checks the CACHED raw child handle and +// kills it if it is still alive. Two properties are load-bearing +// and both are pinned: the timer is `unref()`ed (an unexpired stop +// timer must never hold the process open), and it reads the handle +// captured BEFORE the timer was armed — `child.child` may be nulled +// by the runner's own stop() in the meantime, and a nulled handle +// read at fire time would silently skip the escalation the whole +// cascade exists for. +// +// Why this family's gate is SOFT. The question B5 and B6 each answered +// for their own family was "if the provider declares this capability +// absent, can the endpoint still serve a truthful answer?" — and here +// the answer differs by endpoint, which is the honest answer: +// +// - #13's escalation is webui's OWN child-process management. The +// child was registered on webui's state bus by webui's own runner; +// killing it does not consult a provider, and neither does the +// zombie-claim reset (the 2026-09-20 audit escape hatch, which +// exists precisely for the case where the runner died before its +// own finalize ran). Hard-gating #13 would DELETE the user's only +// way out of a stuck 思考中 panel, in order to express a doubt +// about the GENTLE half of a two-mechanism endpoint. That is the +// `session-export.js` argument again: a missing enrichment must not +// be dressed up as a failure. +// - #69 already HAS a truthful "I could not do it" answer, and it is +// its documented contract: 200 `{ok:true, cancelled:false, warning, +// code, killEndpoint}`. A provider with no interrupt surface produces +// exactly that shape (the notification is inapplicable, which is +// what "no client" already means), so a hard gate would replace an +// accurate 200 with a 501 and teach the frontend a shape it does not +// have today. +// +// So `checkInterruptCapability` REPORTS and never throws, and the 501 +// machinery in `errors.js` stays unused by this family — a policy +// statement, and the suite pins that it stays unused. +// +// What this file deliberately does NOT do: +// +// - It does not own the client-state reset. `resetThinkingClaim` is +// shared with `routes/chat.js#handleSend` (the start-phase failure +// path), so it stays in the route; the DECISION to run it +// (`claimStale`) is computed here and the mutation stays put. +// - It does not own the process-kill policy beyond the bounded timer +// and its two guards. Whether a running session may be killed at all +// is a product question this batch does not reopen (KNOWN DEBT). +// - It does not own the session store, the state bus, the RPC wrapper +// or the config. All four are reached through `await import()`. +// +// Boot-path weight. `app.js` imports the routes, the routes import this +// file, so this file is on the boot path. It statically imports nothing +// heavier than `engine/capabilities.js` and `engine/index.js` (both pure +// declaration modules) and nothing else; `lib/state-bus.js`, +// `lib/mcode-rpc.js` and `lib/config.js` are reached through +// `await import()` inside the data-plane functions. That split is the M1 +// lesson, and it is what lets this module be re-exported from +// `engine/index.js` at all. +// +// Provider selection is M4's job, same as B1 through B6: +// `providerByTransport()` maps a transport to a REGISTERED provider id; +// today only `runtime` has one, so under the default `acp` transport the +// gate reports `gate: "unregistered-transport"` and the interrupt +// proceeds — which is correct, because the pre-M4 behaviour under `acp` +// is the only behaviour this endpoint has ever had. + +import { DEFAULT_ENGINE_PROVIDER_ID, getEngineProvider } from "./index.js"; + +/** + * Transport → registered engine provider id. Absent means "no provider + * claims this transport yet" (M4), NOT "the capability is unavailable" — + * the two answer differently on purpose, exactly as in + * `session-reads.js#providerByTransport`, `session-tree-reads.js`, + * `usage-reads.js`, `account-reads.js`, `session-writes.js` and + * `session-switch.js`, which this mirrors rather than merges: six + * families with separate contracts, and a shared table would force this + * one to inherit another's policy. + * + * Built per call rather than frozen at module scope: `engine/index.js` + * re-exports this module, so a module-level table would read + * `DEFAULT_ENGINE_PROVIDER_ID` while that binding is still in its + * temporal dead zone on a cold `import("./engine/index.js")`. Every + * consumer of the table is a function anyway. + * + * @returns {Readonly>} + */ +function providerByTransport() { + return Object.freeze({ runtime: DEFAULT_ENGINE_PROVIDER_ID }); +} + +// --------------------------------------------------------------------------- +// The bound the whole cascade hangs off +// --------------------------------------------------------------------------- + +/** + * How long the gentle cancel has before webui force-kills the child. + * + * Exported because it is part of #13's contract, not an implementation + * detail: the window is what makes "已停止" mean "已停止" — without a + * bound, a turn that ignores the notification would keep running while + * the UI already reported success, which is the defect the third branch + * of the cascade was written for. + * + * The value is 5000 ms. The file this batch migrated ran 2000 ms; the + * batch plan transcribed the bound as "abort 5s" and the product call + * (2026-10-03) is to take the plan's value — the longer grace gives + * stubborn children more time to finalize, at the cost of "already + * stopped" staying a lie for three extra seconds. See KNOWN DEBT 1 — + * the number is pinned by a named test either way, so a future change + * to it is a deliberate one. + * + * @type {number} + */ +export const STOP_FORCE_KILL_MS = 5000; + +// --------------------------------------------------------------------------- +// The declaration, and the gate policy that goes with it +// --------------------------------------------------------------------------- + +/** + * The declaration this family's engine-facing half needs. + * + * `interrupt` is the honest mapping and it is the same key the + * capability matrix row "中断" names: aborting an in-flight turn. The + * ACP surface behind it is `session/cancel` (a notification) plus + * `abortSession` on the runtime surfaces, and the two providers both + * declare it `full` (see `providers/local-runtime-v2.capabilities.js`). + * + * One declaration covers both endpoints on purpose. #13 is not a + * different capability with a kill in it — the kill is webui's own + * process management, which is exactly why neither endpoint gates hard + * (see the module header). Declaring them separately would invite a + * future edit to make #13 "partial" on the strength of the kill branch + * and quietly produce two policies for one capability. + * + * @type {Readonly>} + */ +export const INTERRUPT_ENDPOINTS = Object.freeze({ + "POST /api/stop": Object.freeze({ + capability: "interrupt", + subItem: "abortSession", + enforcement: "soft", + }), + "POST /api/protocol/cancel": Object.freeze({ + capability: "interrupt", + subItem: "abortSession", + enforcement: "soft", + }), +}); + +/** + * Resolve the provider that answers the interrupt family on `transport`, + * or `null` when none is registered yet. + * + * @param {string} transport One of the `MCODE_WEBUI_TRANSPORT` values. + * @returns {{id: string, transport: string, capabilities: object}|null} + */ +export function resolveInterruptProvider(transport) { + const providerId = providerByTransport()[transport]; + if (!providerId) return null; + return getEngineProvider(providerId); +} + +/** + * Read the declaration for this endpoint WITHOUT enforcing it. + * + * Returns a descriptor whose `gate` field says what happened: + * + * - `"checked"` — provider resolved, capability is `full`. + * - `"unregistered-transport"` — no provider claims this transport yet. + * This is the DEFAULT `acp` transport, and the interrupt proceeding + * here is the pre-M3 behaviour, not a hole in the gate. + * - `"capability-absent"` — the provider WAS found and DOES declare + * the capability as `none`. The caller's next move is to fall back + * to the endpoint's own truthful "I could not deliver it" answer, + * never to fail the request. + * - `"partial"` — provider is `partial` and this sub-item + * is absent; same fallback, said precisely. + * + * Deliberately never throws `EngineCapabilityNotSupportedError`. A + * genuinely unknown endpoint key is still a plain Error — caller + * confusion is not a capability question, and the HTTP layer must never + * answer 501 for a typo in webui's own code. + * + * @param {string} endpoint A key of INTERRUPT_ENDPOINTS. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string|null, subItem: string|null, enforcement: "soft"}} + */ +export function checkInterruptCapability(endpoint, transport) { + const need = INTERRUPT_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `checkInterruptCapability: "${endpoint}" is not part of the interrupt family ` + + `(known: ${Object.keys(INTERRUPT_ENDPOINTS).join(", ")})`, + ); + err.code = "unknown_interrupt_endpoint"; + throw err; + } + const base = { + endpoint, + provider: null, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; + const provider = resolveInterruptProvider(transport); + if (!provider) return { ...base, gate: "unregistered-transport" }; + const entry = provider.capabilities ? provider.capabilities[need.capability] : undefined; + const descriptor = { ...base, provider: provider.id }; + if (entry && entry.level === "full") { + return { ...descriptor, gate: "checked" }; + } + if (entry && entry.level === "partial") { + const absent = Array.isArray(entry.missing) && entry.missing.includes(need.subItem); + return { ...descriptor, gate: absent ? "partial" : "checked" }; + } + // `none`, or no entry at all — the provider was found and does not + // offer this. Report it; the caller degrades to the kill cascade. + return { ...descriptor, gate: "capability-absent" }; +} + +// --------------------------------------------------------------------------- +// Pure derivations. Exported and tested on their INPUTS. +// --------------------------------------------------------------------------- + +/** + * Whether a stop left a client state that nothing will ever put back to + * rest on its own. + * + * True exactly when no active child backs the conversation AND the + * client state still claims a live run. That combination is the + * 2026-09-20 audit's zombie run: the runner died before its finalize + * ran (an acp start-phase failure, a mid-run crash that lost the child + * registration), so every later `pushStateFor` re-asserts + * `running.active=true` and the panel shows 思考中 forever. `/api/stop` + * is the user's escape hatch for exactly that moment. + * + * The negative half is the other half of the rule and is why this is a + * derivation rather than an unconditional reset: when a child IS + * present, the kill cascade rejects the in-flight prompt and the + * runner's OWN finalize owns the terminal state, including its chat + * cursor cleanup. Resetting early would race it and could strip a + * `▍` cursor the stream is still about to rewrite. + * + * @param {boolean} wasRunning Whether an active child backs this cid. + * @param {object|null|undefined} cs The requesting client's state. + * @returns {boolean} + */ +export function stopLeftStaleClaim(wasRunning, cs) { + if (wasRunning) return false; + return !!(cs && cs.running && cs.running.active); +} + +// --------------------------------------------------------------------------- +// Data plane +// --------------------------------------------------------------------------- + +/** + * #13 — the gentle cancel, the kill cascade, and the bounded + * escalation. + * + * The order below IS the endpoint's contract: + * + * 1. LOOK UP THE VIEWED SESSION'S CHILD, not "any child of this tab". + * A tab may run two conversations at once, and stopping must not + * signal the other turn's subprocess — so the lookup is narrowed + * by `(cid, cs.mcodeSessionId)`. + * 2. GENTLE PATH. `session/cancel`, pinned on the same subprocess via + * `cid`. A refusal or a throw is logged and falls through; it is + * never fatal, because the cascade below is the actual promise. + * 3. HARD PATH. `child.kill()` iff a child was registered AND the + * gentle path did not take. This is the only thing `hardKilled` + * reports. + * 4. BOUNDED ESCALATION, armed whenever a child was registered — not + * only when it was killed. A cancel that was "sent" but ignored + * is exactly the case the bound exists for. + * + * The response body is built HERE and never re-assembled in the route, + * so the `hardKilled` / `note` wording has one home. The route keeps the + * client-state reset (`resetThinkingClaim`, shared with `handleSend`) + * and the state push, and both are driven by `claimStale`. + * + * @param {object} options + * @param {object} options.cs The requesting client's state. READ ONLY + * here — the claim reset is the route's, because its helper is + * shared with the send path. + * @param {string} [options.cid] Requesting client id. + * @param {string} [options.transport] Transport override. + * @param {typeof setTimeout} [options.setTimeoutImpl] Injection seam for + * the escalation timer. Defaults to the global; the suite + * injects node:test's mock timers through it so the bound can be + * tested without waiting two real seconds. + * @returns {Promise<{payload: object, claimStale: boolean, gate: object, transport: string}>} + */ +export async function applyEngineStop(options = {}) { + const endpoint = options.endpoint || "POST /api/stop"; + const [bus, rpc, config] = await Promise.all([ + import("../lib/state-bus.js"), + import("../lib/mcode-rpc.js"), + import("../lib/config.js"), + ]); + const transport = options.transport || config.MCODE_WEBUI_TRANSPORT; + const gate = checkInterruptCapability(endpoint, transport); + const cid = options.cid; + const cs = options.cs; + // The VIEWED session's child, not "any child of this tab": a tab may + // run two conversations at once, and stopping must not signal the + // other turn's subprocess. + const child = bus.getActiveChild(cid, cs && cs.mcodeSessionId); + const wasRunning = !!child; + let cancelled = false; + let hardKilled = false; + // 1. Gentle path: send the `session/cancel` notification. The engine + // aborts the active prompt's AbortController; there is no reply, so + // `cancelled` means "sent". + if (cs && cs.mcodeSessionId) { + try { + const r = await rpc.cancelSession(cs.mcodeSessionId, cid); + if (r.ok) cancelled = true; + else { + // No client to notify — worth a line in the log before the SIGKILL. + console.warn(`[stop] session/cancel failed cid=${cid}: ${r.error} (code=${r.code})`); + } + } catch (e) { + console.warn(`[stop] session/cancel threw cid=${cid}: ${e.message}`); + } + } + // 2. 兜底路径: hard kill child (RPC 不支持或失败) + if (child && !cancelled) { + try { + child.kill(); + } catch {} + hardKilled = true; + } + // 3. 兜底路径 2: bound the wait, and force-kill if the child survived + // the gentle path. The raw child_process handle is captured NOW: + // `child.child` may be nulled by the runner's own stop() long + // before the timer fires, and a nulled read at fire time would + // silently skip the escalation the cascade exists for. + if (child) { + const rawChild = child.child; // 缓存 node child_process 实例 + const setTimeoutImpl = options.setTimeoutImpl || setTimeout; + const escalation = setTimeoutImpl(() => { + try { + if (rawChild && !rawChild.killed && rawChild.exitCode === null) { + console.log(`[stop] cid=${cid} child still alive ${STOP_FORCE_KILL_MS}ms after stop, force-killing`); + child.kill(); + } + } catch {} + }, STOP_FORCE_KILL_MS); + // The real `setTimeout` returns a Timeout with `unref`, so an + // unexpired escalation can never hold the process open. The + // injection seam may return a bare id (node:test's mock timers + // return a plain object), and calling `.unref()` unconditionally + // would make the bound untestable — so the call is guarded rather + // than assumed. + if (escalation && typeof escalation.unref === "function") escalation.unref(); + } + return { + payload: { + ok: true, + wasRunning, + cancelled, + hardKilled, + // Names why the gentle path did not happen — NOT what followed. + // A stop with no child and no session id reports the hard-kill + // note while reporting `hardKilled:false`, because that is the + // pre-M3 wording this endpoint has always returned. + note: cancelled + ? "gentle cancel" + : "hard kill (session/cancel could not be delivered)", + }, + claimStale: stopLeftStaleClaim(wasRunning, cs), + gate, + transport, + }; +} + +/** + * #69 — the gentle half on its own. + * + * Deliberately does NOT escalate. The route's own comment records the + * reason and the reason is the contract: `session/cancel` is a + * notification, so the endpoint cannot say whether the prompt actually + * stopped. A refusal therefore answers 200 with `cancelled:false` and + * a pointer to `/api/stop`, which is where the cascade lives — claiming + * a hard kill here would be claiming a kill this handler never performs + * (#110 fake-success, and the existing suite already pins the absence + * of a `fallback` key for exactly that reason). + * + * The body is built here so the two refusal shapes have one home. + * `delivered` is returned separately because the state push is + * conditional on it: the pre-M3 route pushes only when the notification + * was accepted, and a push on a refusal would re-assert the very claim + * the caller just failed to clear. + * + * @param {object} options + * @param {string} options.sessionId Already validated non-empty. + * @param {string} [options.cid] + * @param {string} [options.transport] + * @returns {Promise<{payload: object, delivered: boolean, gate: object, transport: string}>} + */ +export async function sendEngineSessionCancel(options = {}) { + const endpoint = options.endpoint || "POST /api/protocol/cancel"; + const [rpc, config] = await Promise.all([ + import("../lib/mcode-rpc.js"), + import("../lib/config.js"), + ]); + const transport = options.transport || config.MCODE_WEBUI_TRANSPORT; + const gate = checkInterruptCapability(endpoint, transport); + const r = await rpc.cancelSession(options.sessionId, options.cid); + if (!r.ok) { + return { + payload: { + ok: true, + cancelled: false, + warning: r.error, + code: r.code, + killEndpoint: "/api/stop", + }, + delivered: false, + gate, + transport, + }; + } + return { + payload: { ok: true, cancelled: true, data: r.data }, + delivered: true, + gate, + transport, + }; +} + +// --------------------------------------------------------------------------- +// KNOWN DEBT +// --------------------------------------------------------------------------- +// +// Recorded here rather than fixed, because each item is a decision that +// belongs to a human and not to a refactor: +// +// 1. THE PLAN SAID 5s AND THE CODE SAID 2s — RESOLVED 2026-10-03: the +// product call took the plan's value, `STOP_FORCE_KILL_MS` is now +// 5000 (it was 2000 before this batch). The longer grace gives a +// stubborn child more time to finalize but makes "已停止" lie for +// three extra seconds; that trade was accepted explicitly. The +// named test pins the new value, so a future change stays a +// deliberate one. +// +// 2. #13 KILLS A RUNNING TURN WITHOUT ASKING WHETHER IT MAY. A stop +// on an in-flight session SIGKILLs the engine subprocess, and the +// kill cascade runs on the VIEWED session's child without checking +// whether the child belongs to a turn the user still wants. That +// is the pre-facade behaviour and it is arguably the correct one +// (the user pressed stop), but "refuse to stop a turn that has not +// yet produced output" and "escalate only after a second attempt" +// are both defensible alternatives. The same shape is recorded in +// B5's KNOWN DEBT 3 for the delete family, where the mirror-image +// question is "refuse to delete a running session" — between them +// they are the same policy question about running sessions, and it +// deserves one decision rather than two. +// +// 3. `hardKilled` CANNOT MEAN "THE PROCESS IS DEAD", so it does not +// try. The field is written before the escalation timer can fire, +// and neither this endpoint nor `/api/protocol/cancel` observes the +// child's exit. A frontend that wants "is it really gone" has no +// answer on this endpoint today; the state frame after the runner's +// finalize is the closest thing, and it is a different request. If +// that distinction matters to a caller, the fix is a new field +// carrying the escalation's outcome — which means waiting for the +// bound before answering, i.e. giving up the fire-and-forget 200. +// Both halves of that trade are the user's to weigh. diff --git a/packages/webui/server/engine/session-load.js b/packages/webui/server/engine/session-load.js new file mode 100644 index 000000000..ea057e20c --- /dev/null +++ b/packages/webui/server/engine/session-load.js @@ -0,0 +1,578 @@ +// webui/server/engine/session-load.js +// +// Migration step M3, batch B7 (part 2 of 2): the LOAD and ACTIVATE +// family — +// +// #70 POST /api/protocol/load-session — make the engine load a session +// #71 POST /api/protocol/activate-session — point the client at a session +// +// What this file is for. Both endpoints end by mutating webui's own +// state, and before M3 that mutation — the sidebar entry, the +// `mcodeSessionId` rebinding, the context reset, the response body — +// was assembled in `routes/protocol.js`, which reached into +// `lib/mcode-rpc.js#loadSession` / `#activateSession` and +// `lib/sessions.js#loadSessions` / `#saveSessions` / `#resetContext` +// directly. Three facts about that work are load-bearing and none of +// them is visible from the route's edge any more: +// +// 1. #70's SIDEBAR ENTRY IS DOWNSTREAM OF THE ENGINE'S ANSWER, NEVER A +// PEER OF IT. `createWebuiEntry` adds a record to `sessions.json` +// so the sidebar can show a session the TUI started — but it may +// only be created once the engine has confirmed the load. A +// provider that cannot load must not be able to leave a sidebar +// entry pointing at a session the engine never opened, and a +// failed load must not leave one either. This is the invariant that +// decides #70's gate (see below), and it is why the entry creation +// is ordered strictly after the capability check and the engine +// call rather than being a parallel "best effort" branch. +// +// 2. THE ENTRY IS IDEMPOTENT, AND IDEMPOTENCE IS ABOUT THE +// `mcodeSessionId` MATCH, NOT THE CALLER. A second +// `createWebuiEntry` for a session webui already wraps returns the +// EXISTING record without re-saving, so repeated calls cannot grow +// duplicate sidebar entries for one conversation. This is +// pre-existing behaviour and the suite pins both halves (first +// call creates, second call returns the same id and does not grow +// the store). +// +// 3. #71's ORDER IS `mcodeSessionId` FIRST, `resetContext` SECOND. +// `resetContext` re-roots the context panel from the client state, +// so it has to observe the NEW session id — reversing the two +// leaves the panel describing the session the user just left. The +// pair is also the endpoint's entire meaning, which is why the +// response is built here next to it and never re-assembled. +// +// Why this family's gate is SPLIT, and why the split is not a +// compromise between two opinions. +// +// Both endpoints declare the same capability — `sessionCrud`, the same +// key B1 uses for the title read and B5 uses for the delete — but they +// need different answers to "if the provider declares this absent, can +// the endpoint still serve a truthful answer?", and the two answers are +// not close: +// +// - #70 GATES HARD. Its only purpose is to make the engine load a +// session; there is no webui-side fallback, and by fact 1 a +// "success" that skipped the engine would write a sidebar entry +// for a session that does not exist on the engine side. That is the +// fake-success failure mode #110 exists to prevent, in the exact +// shape the gate exists to prevent: a 200 with an entry and no +// session. The 501 machinery in `errors.js` is therefore in use by +// this endpoint, and the router's existing central mapping answers +// it — no route has to remember to catch it. +// +// - #71 GATES SOFT, and the reason is that hard-gating it would BE +// the decision a human has not made yet. The plan (§3a, the +// activate row) records the situation exactly: one ACP client +// tracks a single active session, so "activate another" is how the +// client is re-pointed; the in-process host has NO single-active- +// session concept at all; and the endpoint's fate is therefore an +// either/or — "语义塌缩(cs 切换 + resume), 或 501". Those are two +// different products. Choosing the 501 branch is a real answer to +// that question and this batch is not entitled to give it: it would +// be given silently, by a capability table, with no changelog and +// no frontend work. So `checkSessionActivateCapability` REPORTS, +// the route keeps the pre-M3 shape and status mapping byte for +// byte, and the decision itself is KNOWN DEBT 1 with both branches +// costed. +// +// One family, two gate functions, rather than two modules. B2 split +// `session-tree-reads.js` from `session-export.js` because those two +// endpoints declare DIFFERENT capabilities and their gate MECHANICS +// differ (throw vs report) for unrelated reasons. Here the mechanics +// are the same two functions every other family already uses, both +// endpoints share one capability, and they share a store, a client +// state and a route module. Splitting would duplicate the transport +// table, the resolver and the two status mappers to keep a distinction +// that is one `enforcement` field wide — which is exactly the shape +// B5's mixed `session-writes.js` table already carries, for the same +// reason. +// +// What this file deliberately does NOT do: +// +// - It does not decide what #71 means under a provider without +// single-active-session semantics. See KNOWN DEBT 1. +// - It does not own the session store. `lib/sessions.js` keeps the +// load/save and the overlay rule; this file orders the calls. +// - It does not own the status codes. The `code` → HTTP mapping +// happens here, but the number is returned as `statusHint` and the +// route writes it, so the engine layer never learns what a status +// is. +// - It does not build a host. There is no host on this path at all. +// +// Boot-path weight. `app.js` imports the routes, the routes import this +// file, so this file is on the boot path. It statically imports nothing +// heavier than `engine/index.js` (a pure declaration module) and nothing +// else; `lib/mcode-rpc.js`, `lib/sessions.js` and `lib/config.js` are +// reached through `await import()` inside the data-plane functions. The +// pure derivations below take their dependencies as arguments for the +// same reason twice over: they stay testable without a module +// registry, and the boot path never sees a session-store import. +// +// Provider selection is M4's job, same as B1 through B6: +// `providerByTransport()` maps a transport to a REGISTERED provider id; +// today only `runtime` has one, so under the default `acp` transport the +// gates report `gate: "unregistered-transport"` and both endpoints +// proceed — which is correct, because the pre-M4 behaviour under `acp` +// is the only behaviour these endpoints have ever had. + +import { assertEngineCapability } from "./capabilities.js"; +import { DEFAULT_ENGINE_PROVIDER_ID, getEngineProvider } from "./index.js"; + +/** + * Transport → registered engine provider id. Absent means "no provider + * claims this transport yet" (M4), NOT "the capability is unavailable" — + * the two answer differently on purpose, exactly as in + * `session-reads.js#providerByTransport`, `session-tree-reads.js`, + * `usage-reads.js`, `account-reads.js`, `session-writes.js`, + * `session-switch.js` and `interrupt.js`, which this mirrors rather + * than merges: seven families with separate contracts, and a shared + * table would force this one to inherit another's policy. + * + * Built per call rather than frozen at module scope: `engine/index.js` + * re-exports this module, so a module-level table would read + * `DEFAULT_ENGINE_PROVIDER_ID` while that binding is still in its + * temporal dead zone on a cold `import("./engine/index.js")`. Every + * consumer of the table is a function anyway. + * + * @returns {Readonly>} + */ +function providerByTransport() { + return Object.freeze({ runtime: DEFAULT_ENGINE_PROVIDER_ID }); +} + +// --------------------------------------------------------------------------- +// The declaration, and the two gate policies that go with it +// --------------------------------------------------------------------------- + +/** + * The declaration this family's engine-facing half needs. + * + * `sessionCrud` is the honest mapping: loading a session and activating + * a session are both "point an engine at a session", which is what + * B1's `getSession` read, B5's `deleteSession` write and B6's soft + * `getSession` all name. Both providers declare it `full` today, which + * is precisely why #70's hard gate costs nothing on the current + * two-transport matrix — a hard gate is only observable once some + * provider declares the sub-item absent, and that is M4's problem to + * answer with evidence rather than this batch's to pre-empt. + * + * @type {Readonly>} + */ +export const SESSION_LOAD_ENDPOINTS = Object.freeze({ + "POST /api/protocol/load-session": Object.freeze({ + capability: "sessionCrud", + subItem: "loadSession", + enforcement: "hard", + }), + "POST /api/protocol/activate-session": Object.freeze({ + capability: "sessionCrud", + subItem: "activateSession", + enforcement: "soft", + }), +}); + +/** + * Resolve the provider that answers the load/activate family on + * `transport`, or `null` when none is registered yet. + * + * @param {string} transport One of the `MCODE_WEBUI_TRANSPORT` values. + * @returns {{id: string, transport: string, capabilities: object}|null} + */ +export function resolveSessionLoadProvider(transport) { + const providerId = providerByTransport()[transport]; + if (!providerId) return null; + return getEngineProvider(providerId); +} + +/** + * HARD gate — #70 only. Throws `EngineCapabilityNotSupportedError` for + * a declared `none` (or for a `partial` naming this sub-item), which + * the router maps to 501 with `engineCapabilityHttpResponse`'s payload. + * + * Unlike its soft sibling below, an unknown endpoint key is ALSO a + * plain Error: the hard path is the one whose 501 body a client can + * see, and a typo in webui's own key must never be reported to a user + * as an engine limitation. + * + * @param {string} endpoint A key of SESSION_LOAD_ENDPOINTS. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string, subItem: string, enforcement: "hard"}} + */ +export function assertSessionLoadCapability(endpoint, transport) { + const need = SESSION_LOAD_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `assertSessionLoadCapability: "${endpoint}" is not part of the load/activate family ` + + `(known: ${Object.keys(SESSION_LOAD_ENDPOINTS).join(", ")})`, + ); + err.code = "unknown_session_load_endpoint"; + throw err; + } + const provider = resolveSessionLoadProvider(transport); + if (!provider) { + return { + endpoint, + gate: "unregistered-transport", + provider: null, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; + } + const entry = provider.capabilities ? provider.capabilities[need.capability] : undefined; + if (entry && entry.level === "full") { + return { + endpoint, + gate: "checked", + provider: provider.id, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; + } + // Throws for `partial` with this sub-item missing, and for `none` / + // no entry at all. + assertEngineCapability(provider.capabilities, need.capability, provider.id, need.subItem); + return { + endpoint, + gate: "partial", + provider: provider.id, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; +} + +/** + * SOFT gate — #71 only. Reports and never throws; see the module header + * for why hard-gating the activate endpoint would be taking the + * decision this batch is required to leave open. + * + * @param {string} endpoint A key of SESSION_LOAD_ENDPOINTS. + * @param {string} transport The active transport. + * @returns {{endpoint: string, gate: string, provider: string|null, capability: string, subItem: string, enforcement: "soft"}} + */ +export function checkSessionActivateCapability(endpoint, transport) { + const need = SESSION_LOAD_ENDPOINTS[endpoint]; + if (need === undefined) { + const err = new Error( + `checkSessionActivateCapability: "${endpoint}" is not part of the load/activate family ` + + `(known: ${Object.keys(SESSION_LOAD_ENDPOINTS).join(", ")})`, + ); + err.code = "unknown_session_load_endpoint"; + throw err; + } + const base = { + endpoint, + provider: null, + capability: need.capability, + subItem: need.subItem, + enforcement: need.enforcement, + }; + const provider = resolveSessionLoadProvider(transport); + if (!provider) return { ...base, gate: "unregistered-transport" }; + const entry = provider.capabilities ? provider.capabilities[need.capability] : undefined; + const descriptor = { ...base, provider: provider.id }; + if (entry && entry.level === "full") { + return { ...descriptor, gate: "checked" }; + } + if (entry && entry.level === "partial") { + const absent = Array.isArray(entry.missing) && entry.missing.includes(need.subItem); + return { ...descriptor, gate: absent ? "partial" : "checked" }; + } + // `none`, or no entry at all — report it. The caller keeps the + // pre-M3 shape; the decision is KNOWN DEBT 1. + return { ...descriptor, gate: "capability-absent" }; +} + +// --------------------------------------------------------------------------- +// Pure derivations. Exported and tested on their INPUTS. +// --------------------------------------------------------------------------- + +/** + * #70's `code` → HTTP status, and the `no_client` case is the only one + * that is not a straight default. + * + * The map is deliberately NOT the one #67/`set-mode` uses, and the + * difference is load-bearing: `set-mode` answers 501 for + * `code === "unsupported"`, #70 answers 500. The existing suite pins + * that asymmetry ("handleLoadSession has DIFFERENT mapping than + * setMode"), and unifying them would be a behaviour change to two + * endpoints at once, not a migration step. + * + * Extracted as a pure function rather than left inline so the whole + * table can be asserted on its inputs, including the rows no fixture + * reaches. + * + * @param {string|undefined} code The RPC wrapper's `code`. + * @returns {number} + */ +export function loadFailureStatus(code) { + if (code === "no_client") return 503; + if (code && /not.found|invalid/i.test(code)) return 404; + return 500; +} + +/** + * #70's wire `code`, which is not always the code it received. + * + * The engine answers "Resource not found" as `-32004` / + * `resource_not_found`, neither of which reads as a session problem to + * a frontend. The rewrite to `session_not_found` is the endpoint's + * documented contract and is asserted as a value, not as a regex: an + * undefined code stays undefined so `JSON.stringify` drops the key + * exactly as it did before this batch. + * + * @param {string|undefined} code The RPC wrapper's `code`. + * @returns {string|undefined} + */ +export function loadFailureWireCode(code) { + if (code && /not.found|resource/i.test(code)) return "session_not_found"; + return code; +} + +/** + * #71's `code` → HTTP status. Same shape as #70's with one extra row: + * `unsupported` answers 501 here, matching `set-mode` and + * `set-config-option`. That is the pre-M3 mapping and it is preserved + * byte for byte — see the module header for why a hard CAPABILITY gate + * (which would answer a different 501, with a different body) is not + * the same thing as this one and must not quietly replace it. + * + * @param {string|undefined} code The RPC wrapper's `code`. + * @returns {number} + */ +export function activateFailureStatus(code) { + if (code === "unsupported") return 501; + if (code === "no_client") return 503; + if (code && /not.found|invalid/i.test(code)) return 404; + return 500; +} + +// --------------------------------------------------------------------------- +// Data plane +// --------------------------------------------------------------------------- + +/** + * #70 — load a session on the engine, and optionally wrap it for the + * sidebar. + * + * The order below IS the endpoint's contract: + * + * 1. HARD CAPABILITY CHECK. Throws for a provider that declares the + * capability absent; the router answers 501. Under the default + * `acp` transport no provider is registered and the check reports + * `unregistered-transport`, which is the pre-M3 behaviour. + * 2. ENGINE LOAD. `sessionId` and the resolved `cwd` go to the engine. + * The cwd precedence — explicit argument, else the client's current + * workspace, else `""` — is the caller's, and it is kept verbatim. + * 3. STATUS + WIRE CODE. `loadFailureStatus` / `loadFailureWireCode`. + * Nothing below this point runs on a failure, so a failed load can + * never leave a sidebar entry behind. + * 4. SIDEBAR ENTRY, only when the caller asked for one AND the engine + * answered. Idempotent on `mcodeSessionId`. + * + * The response body is built HERE and never re-assembled in the route. + * `webuiEntry` is `null` — not omitted, not `{}` — when no entry was + * requested, because that literal is in the pinned wire shape. + * + * @param {object} options + * @param {string} options.sessionId Already validated non-empty. + * @param {string} [options.cwd] Explicit cwd; falls back to the + * client's workspace. + * @param {boolean} [options.createWebuiEntry] + * @param {object} [options.cs] The requesting client's state; read + * for its workspace only, and never mutated. + * @param {string} [options.transport] + * @param {() => string} [options.newId] Injection seam for the entry's + * id. Defaults to `crypto.randomUUID`; the suite injects a + * counter so the created record is assertable. + * @returns {Promise<{payload: object, statusHint: number, gate: object, transport: string}>} + */ +export async function loadEngineSession(options = {}) { + const endpoint = options.endpoint || "POST /api/protocol/load-session"; + const [rpc, sessions, config] = await Promise.all([ + import("../lib/mcode-rpc.js"), + import("../lib/sessions.js"), + import("../lib/config.js"), + ]); + const transport = options.transport || config.MCODE_WEBUI_TRANSPORT; + const gate = assertSessionLoadCapability(endpoint, transport); + const cs = options.cs; + const sessionId = options.sessionId; + const r = await rpc.loadSession(sessionId, options.cwd || (cs && cs.workspace && cs.workspace.dir) || ""); + if (!r.ok) { + return { + payload: { ok: false, error: r.error, code: loadFailureWireCode(r.code) }, + statusHint: loadFailureStatus(r.code), + gate, + transport, + }; + } + let webuiEntry = null; + if (options.createWebuiEntry && cs) { + // 在 webui session db 创建 entry, 让 sidebar 1:1 看到这个 mcode session + const all = sessions.loadSessions(); + const existing = all.find((s) => s.mcodeSessionId === sessionId); + if (existing) { + webuiEntry = existing; + } else { + const newId = options.newId || (await import("node:crypto")).randomUUID; + webuiEntry = { + id: newId(), + mcodeSessionId: sessionId, + title: "Mcode session", + workspace: options.cwd || (cs.workspace && cs.workspace.dir) || "", + createdAt: Date.now(), + updatedAt: Date.now(), + chat: [], + }; + all.unshift(webuiEntry); + sessions.saveSessions(all); + } + // 不自动切到 webui 当前 session (调用方决定) + } + return { + payload: { ok: true, sessionId, webuiEntry }, + statusHint: 200, + gate, + transport, + }; +} + +/** + * #71 — point the engine AND webui's client state at a session. + * + * The order below IS the endpoint's contract: + * + * 1. SOFT CAPABILITY CHECK. Reports only; see the module header. + * 2. ENGINE ACTIVATE. + * 3. STATUS. `activateFailureStatus`. + * 4. `cs.mcodeSessionId = sessionId`, THEN `resetContext(cs)` — the + * order is the endpoint's meaning, and reversing it leaves the + * context panel describing the session the user just left. + * + * The response body is built here, next to the mutation, and the state + * push stays in the route because it is a transport concern and must + * not fire when the activate failed. + * + * @param {object} options + * @param {string} options.sessionId Already validated non-empty. + * @param {object} [options.cs] Mutated on success only. + * @param {string} [options.transport] + * @returns {Promise<{payload: object, statusHint: number, gate: object, transport: string}>} + */ +export async function activateEngineSession(options = {}) { + const endpoint = options.endpoint || "POST /api/protocol/activate-session"; + const [rpc, sessions, config] = await Promise.all([ + import("../lib/mcode-rpc.js"), + import("../lib/sessions.js"), + import("../lib/config.js"), + ]); + const transport = options.transport || config.MCODE_WEBUI_TRANSPORT; + const gate = checkSessionActivateCapability(endpoint, transport); + const sessionId = options.sessionId; + const r = await rpc.activateSession(sessionId); + if (!r.ok) { + return { + payload: { ok: false, error: r.error, code: r.code }, + statusHint: activateFailureStatus(r.code), + gate, + transport, + }; + } + const cs = options.cs; + if (cs) { + cs.mcodeSessionId = sessionId; + sessions.resetContext(cs); + } + return { + payload: { ok: true, activeSessionId: sessionId, data: r.data }, + statusHint: 200, + gate, + transport, + }; +} + +// --------------------------------------------------------------------------- +// KNOWN DEBT +// --------------------------------------------------------------------------- +// +// Recorded here rather than fixed, because each item is a decision that +// belongs to a human and not to a refactor: +// +// 1. #71's SEMANTIC COLLAPSE IS UNDECIDED, and this batch's only move +// was to NOT decide it. The situation, from the plan (§3a): one +// ACP client tracks a single active session, so `session/activate` +// is how the client is re-pointed at another one; the in-process +// host has no single-active-session concept at all; and the plan +// gives the endpoint's fate as an either/or — "语义塌缩(cs 切换 + +// resume), 或 501". Both branches, with what each costs: +// +// a. COLLAPSE INTO "switch + resume". #70 already loads an +// arbitrary session onto the engine, and B6's #3 already +// rebinds webui's client state onto an arbitrary session. So +// the collapsed endpoint is very nearly the COMPOSITION of the +// two endpoints this batch already has behind the facade. The +// cost is not the implementation, it is the RESPONSE SHAPE: +// today's body is `{ok, activeSessionId, data}` where `data` is +// the engine's raw `session/activate` reply, and a collapsed +// endpoint has no such reply to forward. It would have to grow +// the switch payload (B6's `{ok, session:{…}}`, a much larger +// and byte-pinned object) or invent a new one — and either way +// the frontend, the docs and the two-language documentation all +// change. It also changes the endpoint's MEANING: "activate" +// today mutates nothing in webui's state beyond the two lines +// in step 4, while "switch" re-roots the workspace, the chat +// buffer and the context counters. A frontend that keeps +// calling it as activate would suddenly get a workspace change. +// b. 501 WHEN NO PROVIDER CAN ACTIVATE. Cheap to build — the +// hard-gate machinery already exists in this very file for +// #70, and the router already maps the error. The costs are +// elsewhere: it is a user-visible behaviour change on a route +// the frontend calls today, it needs the §4.2 UI degradation +// (hide or disable the entry point, not an error toast), and +// it would fire for EVERY provider that has no single-active- +// session concept — which, per the plan, is the in-process host +// the default runtime transport is built on. In other words the +// 501 branch most likely lands on the transport with the most +// users, for an endpoint that works today. +// The tie-breaker is product knowledge this batch does not have: +// who calls #71, and what they expect to happen to the sidebar, +// the chat buffer and the workspace when it returns 200. That is +// the question to ask; the answer decides the branch. Until then +// the endpoint keeps its pre-M3 shape and status mapping, and +// `checkSessionActivateCapability` keeps reporting. +// +// 2. #70's SIDEBAR ENTRY IS STILL A WEBUI-SIDE WRITE THE ENGINE +// KNOWS NOTHING ABOUT. The same asymmetry B6 recorded for the +// switch's first-touch overlay: webui's wrapper list and the +// engine's own session list are two different questions that +// happen to agree. Pre-existing behaviour, unchanged here, and +// closing it means deciding who owns session identity. +// +// 3. #70's 501 IS THE GATE'S 501, NOT THE ROUTE'S. `loadSession` +// answers 500 for `code === "unsupported"`, while a provider that +// declares `sessionCrud.loadSession` absent answers 501 with +// `engineCapabilityHttpResponse`'s body. Two different 501s and +// two different bodies can therefore reach this one route, and +// only the second has ever existed. The router's central mapping +// is what keeps them from being confused for each other, and a +// frontend that special-cases the 501 will see the engine-gate +// body first and the RPC-`unsupported` 500 never. Worth +// confirming against the frontend before M4 registers a provider +// that can trip it. +// +// 4. #70's "Resource not found" REWRITE ONLY MATCHES THE STRING FORM. +// The route comment names both shapes the engine can answer with — +// `-32004` and `resource_not_found` — but the rewrite regex +// (`/not.found|resource/i`) only matches the second, so a numeric +// JSON-RPC code reaches the frontend verbatim behind a 500. +// `lib/mcode-rpc.js` puts `e.data.code` into `code`, and that is +// the string form in practice, which is why the gap has never been +// observed. Both behaviours are pinned as-is: widening the regex +// would change a wire shape, and the wider question — whether a +// JSON-RPC numeric code should be translated here at all, or +// normalised once in `mcode-rpc.js` for every caller — is a change +// to the RPC wrapper's contract, not to this endpoint. diff --git a/packages/webui/server/routes/chat.js b/packages/webui/server/routes/chat.js index be02e28b5..23e9d8577 100644 --- a/packages/webui/server/routes/chat.js +++ b/packages/webui/server/routes/chat.js @@ -14,7 +14,6 @@ import { import { pushStateFor, pushAlert, - getActiveChild, beginRun, endRun, moveRunSession, @@ -37,7 +36,18 @@ import { } from "../lib/interaction/command-registry.js"; import { runMcodeAcp } from "../lib/mcode-acp.js"; import { collectExecResult, runMcodeExec } from "../lib/mcode-exec.js"; -import { cancelSession } from "../lib/mcode-rpc.js"; +// M3-B7 (engine facade): #13 `/api/stop` no longer reaches into +// `lib/mcode-rpc.js#cancelSession` and `lib/state-bus.js#getActiveChild` +// from inside the handler. The gentle cancel, the kill cascade, the +// bounded escalation timer and the `hardKilled` / `note` wording all +// live in `engine/interrupt.js#applyEngineStop`, which declares the +// family's `interrupt` capability and gates it SOFT — the escalation is +// webui's own child management, so the endpoint can always answer +// truthfully. See that module's header for the three facts the route no +// longer knows: `cancelled` means "sent", `hardKilled` is a report +// about the first decision rather than about the process, and the +// escalation bound is part of the contract. +import { applyEngineStop } from "../engine/interrupt.js"; import { DEFAULT_MODEL } from "../lib/config.js"; import { resolveAttachments } from "../lib/attachments.js"; import { readJson } from "../lib/read-json.js"; @@ -422,55 +432,27 @@ export async function handleSend(req, res, ctx) { // and finalize runs. SIGKILL is the fallback for a child that cannot be told to // stop at all — killing the process takes its background tasks down with it, // which is why the graceful path is tried first. +// +// M3-B7 (engine facade): all of that — the child lookup narrowed to the +// VIEWED session, the notification, the kill decision, the bounded +// escalation timer and the response body — happens in +// `engine/interrupt.js#applyEngineStop`. What stays HERE is what is +// genuinely the route's: +// +// - The zombie-claim reset, because `resetThinkingClaim` is SHARED +// with `handleSend` (the start-phase failure path above) and +// moving it would have been a second, unrelated change to the send +// route. The DECISION to run it is the engine's (`claimStale`); +// only the mutation is here. +// - The state push, which must not fire when the reset did not run — +// that is what the 2026-09-20 audit's escape hatch is for. +// - The response itself, written from the body the engine built. The +// `note` / `hardKilled` wording is NOT reconstructed here, because +// it has to agree with the cascade that actually ran. export async function handleStop(_req, res, ctx) { const cid = ctx.cid; const cs = ctx.cs; - // The VIEWED session's child, not "any child of this tab": a tab may run - // two conversations at once, and stopping must not signal the other - // turn's subprocess. - const child = getActiveChild(cid, cs && cs.mcodeSessionId); - const wasRunning = !!child; - let cancelled = false; - let hardKilled = false; - // 1. Gentle path: send the `session/cancel` notification. The engine aborts the - // active prompt's AbortController; there is no reply, so `ok` means "sent". - if (cs && cs.mcodeSessionId) { - try { - const r = await cancelSession(cs.mcodeSessionId, ctx.cid); - if (r.ok) cancelled = true; - else { - // No client to notify — worth a line in the log before the SIGKILL. - console.warn( - `[stop] session/cancel failed cid=${cid}: ${r.error} (code=${r.code})`, - ); - } - } catch (e) { - console.warn(`[stop] session/cancel threw cid=${cid}: ${e.message}`); - } - } - // 2. 兜底路径: hard kill child (RPC 不支持或失败) - if (child && !cancelled) { - try { - child.kill(); - } catch {} - hardKilled = true; - } - // 3. 兜底路径 2: 设个 2s timeout, 如果 mcode acp 没通过 cancel 退出, 也强 kill - // (避免 mcode 还在 prompt 不响应时 webui 显示 "已停止" 但实际还在跑) - // 缓存 child.child 引用, 因为 2s 后 child.stop() 可能已经把它置 null - if (child) { - const rawChild = child.child; // 缓存 node child_process 实例 - setTimeout(() => { - try { - if (rawChild && !rawChild.killed && rawChild.exitCode === null) { - console.log( - `[stop] cid=${cid} child still alive 2s after stop, force-killing`, - ); - child.kill(); - } - } catch {} - }, 2000).unref(); - } + const r = await applyEngineStop({ cs, cid }); // v2 (2026-09-20 webui-manual-audit): zombie-run claim reset. If no // active child backs this cid but cs still claims an active run // (runner died before its finalize ran — e.g. the acp start-phase @@ -484,22 +466,12 @@ export async function handleStop(_req, res, ctx) { // cs — the kill cascade above rejects the in-flight prompt and // the runner's own finalize() owns the terminal state (including // its chat-cursor cleanup), so resetting early would only race it. - if (!wasRunning && cs && cs.running && cs.running.active) { + if (r.claimStale) { resetThinkingClaim(cs); pushStateFor(cid); } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); - return res.end( - JSON.stringify({ - ok: true, - wasRunning, - cancelled, - hardKilled, - note: cancelled - ? "gentle cancel" - : "hard kill (session/cancel could not be delivered)", - }), - ); + return res.end(JSON.stringify(r.payload)); } // POST /api/cmd — webui button-driven commands diff --git a/packages/webui/server/routes/protocol.js b/packages/webui/server/routes/protocol.js index 10d49047b..1931a39cd 100644 --- a/packages/webui/server/routes/protocol.js +++ b/packages/webui/server/routes/protocol.js @@ -15,9 +15,6 @@ import { setMode, setConfigOption, - cancelSession, - loadSession, - activateSession, mcodePermissionToWebui, } from "../lib/mcode-rpc.js"; // M3-B1 (engine facade): only #72 (`list-sessions`) is gated in that @@ -26,6 +23,21 @@ import { // set-config-option), each of which lands its own facade call with its // own regression evidence. import { readEngineSessionList } from "../engine/session-reads.js"; +// M3-B7 (engine facade): #69 (`cancel`), #70 (`load-session`) and #71 +// (`activate-session`) now ask the facade. Two modules, because the +// families' gate policies are opposite and one module would force one +// to inherit the other's — the same split B2 drew between the tree +// read and the export enrichment. `interrupt.js` holds the SOFT +// declaration for the cancel pair (a provider without an interrupt +// surface still gets a truthful "I could not deliver it" answer); +// `session-load.js` holds #70's HARD `sessionCrud` · `loadSession` +// gate — the only hard gate in B7, and the one that keeps a sidebar +// entry from being written for a session the engine never loaded — +// beside #71's SOFT one, which is soft precisely because hard-gating it +// would be deciding the semantic-collapse question KNOWN DEBT 1 in that +// module's header says is still open. +import { sendEngineSessionCancel } from "../engine/interrupt.js"; +import { loadEngineSession, activateEngineSession } from "../engine/session-load.js"; // M3-B4 (engine facade): #73 (`capabilities`) now reads the engine's // declared capability surface through the facade instead of reaching // into `lib/mcode-rpc.js` and `lib/acp-client.js` from inside the @@ -33,7 +45,6 @@ import { readEngineSessionList } from "../engine/session-reads.js"; // the `engine` view rather than replacing the ACP wire table, and why // this endpoint declares no capability of its own. import { readEngineCapabilityView } from "../engine/capability-reads.js"; -import { loadSessions, saveSessions, resetContext } from "../lib/sessions.js"; import { pushStateFor } from "../lib/state-bus.js"; import { readJson } from "../lib/read-json.js"; @@ -126,28 +137,29 @@ export async function handleSetConfigOption(req, res, ctx) { // ============================================================ // POST /api/protocol/cancel { sessionId } // 取消正在跑的 prompt。比 child.kill() 温和: 让 mcode 走完 finalize,而不是直接 SIGKILL +// +// M3-B7 (engine facade): the notification and the two response shapes +// live in `engine/interrupt.js#sendEngineSessionCancel`. What stays +// here is the route's: the 400 for a missing sessionId, the state +// push, and the rule that the push fires ONLY when the notification +// was actually delivered — a push on a refusal would re-assert the +// very claim the caller just failed to clear, and that conditional is +// the part the engine layer has no business knowing about. +// +// The endpoint still does NOT escalate: `session/cancel` is a +// notification, so the route cannot say whether the prompt stopped. A +// refusal therefore answers 200 with `cancelled:false` and a pointer +// to `/api/stop`, which is where the gentle-then-SIGKILL cascade lives. +// Claiming a hard kill here would be claiming a kill this handler +// never performs. // ============================================================ export async function handleCancel(req, res, ctx) { const { sessionId } = await readJson(req); if (!sessionId) return respond(res, 400, { ok: false, error: "sessionId required" }); - const r = await cancelSession(sessionId, ctx && ctx.cid); - // A refusal means the `session/cancel` notification could not be delivered — - // it is a notification (no reply), so we cannot say whether the prompt - // actually stopped. This route only sends the notification; the - // gentle-then-SIGKILL cascade lives behind POST /api/stop, which the - // caller can request explicitly if the kill cascade is what they wanted. - if (!r.ok) { - return respond(res, 200, { - ok: true, - cancelled: false, - warning: r.error, - code: r.code, - killEndpoint: "/api/stop", - }); - } - if (ctx && ctx.cid) pushStateFor(ctx.cid); - return respond(res, 200, { ok: true, cancelled: true, data: r.data }); + const r = await sendEngineSessionCancel({ sessionId, cid: ctx && ctx.cid }); + if (r.delivered && ctx && ctx.cid) pushStateFor(ctx.cid); + return respond(res, 200, r.payload); } // ============================================================ @@ -155,85 +167,63 @@ export async function handleCancel(req, res, ctx) { // 加载任意 mcode session (含 TUI 跑的)。 // - 默认: 仅在 mcode 端 load, 不动 webui session // - createWebuiEntry=true: 同时在 webui session db 创建 entry (用于 sidebar 显示) +// +// M3-B7 (engine facade): the hard `sessionCrud` · `loadSession` gate, +// the engine load, the `code` → status and → wire-code mappings, and +// the idempotent sidebar entry all live in +// `engine/session-load.js#loadEngineSession`. The gate throws for a +// provider that declares the capability absent, and the router's +// existing central mapping answers it 501 — this route does not catch +// it, and must not: that 501 is the "the engine cannot do this" answer +// and folding it into a status table here would turn it into a 500. +// +// What stays is the route's: the 400, the status write, and the state +// push (which is unconditional on success, as it always was). // ============================================================ export async function handleLoadSession(req, res, ctx) { const { sessionId, cwd, createWebuiEntry } = await readJson(req); if (!sessionId) return respond(res, 400, { ok: false, error: "sessionId required" }); - const r = await loadSession(sessionId, cwd || ctx?.cs?.workspace?.dir || ""); - if (!r.ok) { - const httpCode = - r.code === "no_client" - ? 503 - : r.code && /not.found|invalid/i.test(r.code) - ? 404 - : 500; - // mcode acp "Resource not found" 返 404 的子情况, code 是 -32004 / 'resource_not_found' - // 给前端更可读的 code - const outCode = - r.code && /not.found|resource/i.test(r.code) - ? "session_not_found" - : r.code; - return respond(res, httpCode, { ok: false, error: r.error, code: outCode }); - } - let webuiEntry = null; - if (createWebuiEntry && ctx && ctx.cs) { - // 在 webui session db 创建 entry, 让 sidebar 1:1 看到这个 mcode session - const all = loadSessions(); - const existing = all.find((s) => s.mcodeSessionId === sessionId); - if (existing) { - webuiEntry = existing; - } else { - const { randomUUID } = await import("node:crypto"); - webuiEntry = { - id: randomUUID(), - mcodeSessionId: sessionId, - title: "Mcode session", - workspace: cwd || ctx.cs.workspace?.dir || "", - createdAt: Date.now(), - updatedAt: Date.now(), - chat: [], - }; - all.unshift(webuiEntry); - saveSessions(all); - } - // 不自动切到 webui 当前 session (调用方决定) - } + const r = await loadEngineSession({ + sessionId, + cwd, + createWebuiEntry, + cs: ctx && ctx.cs, + }); + if (r.statusHint !== 200) return respond(res, r.statusHint, r.payload); if (ctx && ctx.cid) pushStateFor(ctx.cid); - return respond(res, 200, { ok: true, sessionId, webuiEntry }); + return respond(res, 200, r.payload); } // ============================================================ // POST /api/protocol/activate-session { sessionId } // 切到指定 mcode session +// +// M3-B7 (engine facade): the soft gate, the engine activate, the +// status mapping, the `mcodeSessionId` rebinding and the `resetContext` +// that follows it all live in +// `engine/session-load.js#activateEngineSession`. +// +// The gate is SOFT and the response shape is unchanged on purpose. The +// endpoint's fate is an open product question — the plan (§3a) gives it +// as "语义塌缩(cs 切换 + resume), 或 501", and hard-gating it would +// be silently choosing the second. KNOWN DEBT 1 in that module's +// header costs both branches. The 501 this route can still answer is +// the PRE-EXISTING one, from `code === "unsupported"` — a different +// status with a different body, and the two must not be confused for +// each other. +// +// What stays is the route's: the 400, the status write, and the state +// push (success only, as always). // ============================================================ export async function handleActivateSession(req, res, ctx) { const { sessionId } = await readJson(req); if (!sessionId) return respond(res, 400, { ok: false, error: "sessionId required" }); - const r = await activateSession(sessionId); - if (!r.ok) { - // Same status mapping as set-mode / set-config-option. - const httpCode = - r.code === "unsupported" - ? 501 - : r.code === "no_client" - ? 503 - : r.code && /not.found|invalid/i.test(r.code) - ? 404 - : 500; - return respond(res, httpCode, { ok: false, error: r.error, code: r.code }); - } - if (ctx && ctx.cs) { - ctx.cs.mcodeSessionId = sessionId; - resetContext(ctx.cs); - } + const r = await activateEngineSession({ sessionId, cs: ctx && ctx.cs }); + if (r.statusHint !== 200) return respond(res, r.statusHint, r.payload); if (ctx && ctx.cid) pushStateFor(ctx.cid); - return respond(res, 200, { - ok: true, - activeSessionId: sessionId, - data: r.data, - }); + return respond(res, 200, r.payload); } // ============================================================ diff --git a/packages/webui/test/lib/engine/interrupt.test.js b/packages/webui/test/lib/engine/interrupt.test.js new file mode 100644 index 000000000..2eae20f47 --- /dev/null +++ b/packages/webui/test/lib/engine/interrupt.test.js @@ -0,0 +1,958 @@ +// webui/test/lib/engine/interrupt.test.js +// +// M3-B7 (part 1): the INTERRUPT family's engine facade — #13 +// POST /api/stop and #69 POST /api/protocol/cancel. +// +// Sections are ordered by how much user-visible damage a regression in +// each one does, not by which module the function came from: +// +// 1. THE DECLARATION AND ITS SOFT-GATE POLICY. The judgement call in +// this half of the batch: both endpoints gate SOFT, for +// endpoint-specific reasons, and the suite proves the gate reports +// and never throws — including on the DEFAULT `acp` transport, +// where no provider is registered at all. +// 2. THE FOUR RED LINES. 中断有界 (the bounded escalation), the +// `hardKilled` field's meaning, `cancelled` meaning "sent" rather +// than "stopped", and the existing degradation each endpoint +// already had. One named test per line, plus the NEGATIVE half of +// each, because a red line only asserted in its happy direction is +// a red line nobody is watching. +// 3. THE BYTE-FOR-BYTE WIRE SHAPES, table-driven across every branch: +// status, Content-Type, the exact body string and the key ORDER. +// 4. THE PURE DERIVATIONS, on their inputs. +// 5. THE ROUTE, with the proof that the facade mock actually took. +// +// Two module-mock traps apply here exactly as they did in B3 through B6, +// and both are load-bearing rather than incidental: +// +// 1. `t.mock.module` REPLACES the WHOLE NAMESPACE; it does not merge. +// A mock naming only the export under test leaves every other name +// undefined and the consumer fails at INSTANTIATION with +// `SyntaxError: … does not provide an export named …` — a failure +// that reads like a product bug and is not one. Every facade mock +// below goes through `mockAll()`, which fills the un-stubbed names +// with a function that THROWS, so an unexpected call is loud +// instead of returning a plausible payload. +// 2. `mock.module` re-evaluates only the MOCKED specifier. A consumer +// already in the registry keeps its old LIVE BINDING, so a second +// test in the same file would silently reuse the first test's mock +// and pass for the wrong reason. Every route re-import in section 5 +// carries a fresh `?bust=N`, and section 5 ends with marker +// controls that prove it. +// +// The escalation bound is exercised through the `setTimeoutImpl` +// injection seam rather than by waiting. The seam exists for that +// purpose and the bound itself is pinned two ways: the delay argument +// is asserted to EQUAL `STOP_FORCE_KILL_MS` (so the number has one +// home), and a real-`setTimeout` case in section 5 drives node:test's +// mock timers end to end so the production wiring — including the +// `unref` guard — is proven rather than assumed. + +import { test, describe, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; + +import { setupMocks, absPath, registerRpcMock } from "../../helpers/_setup.js"; +// Type discrimination goes through the exported predicate, never +// `err.name`. `engine/capabilities.js` is never `mock.module`d by this +// file, so the `instanceof` inside it resolves against the same class +// `assertEngineCapability` would have thrown from. The string comparison +// it replaces could not tell a capability error from any other error +// that happened to carry a name. +const { isEngineCapabilityNotSupportedError } = await import( + "../../../server/engine/errors.js" +); + +const RUNTIME = "runtime"; +const ACP = "acp"; + +/** A syntactically valid engine sid. */ +const SID = "mvs_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +let bust = 0; + +/** Every name `engine/interrupt.js` exports. The namespace, not a subset. */ +const FACADE_EXPORTS = [ + "INTERRUPT_ENDPOINTS", + "STOP_FORCE_KILL_MS", + "applyEngineStop", + "checkInterruptCapability", + "resolveInterruptProvider", + "sendEngineSessionCancel", + "stopLeftStaleClaim", +]; + +/** A JSON request body the real `lib/read-json.js` can consume. */ +function jsonReq(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} + +/** A minimal `ServerResponse` stand-in that records what was written. */ +function mkRes() { + const written = []; + return { + written, + writeHead(status, headers) { + written.push({ status, headers }); + return this; + }, + end(body) { + written.push({ body }); + return this; + }, + }; +} + +/** The last `writeHead` + `end` pair, as one observation. */ +function lastResponse(res) { + const head = res.written[res.written.length - 2]; + const tail = res.written[res.written.length - 1]; + assert.ok(head && head.status !== undefined, "the handler never wrote a head"); + return { status: head.status, headers: head.headers, body: tail ? tail.body : undefined }; +} +/** A client state carrying only what the stop path reads. */ +function mkCs(overrides = {}) { + return { + mcodeSessionId: SID, + chat: [], + context: {}, + running: { active: false, prompt: null, pid: null, sessionId: null }, + ...overrides, + }; +} + +/** + * A fake child registered on the state bus. `child.child` is the raw + * handle the escalation reads, and it is a DISTINCT object from the + * wrapper precisely so a test can null one without the other — the + * property the "cached handle" red line is about. + */ +function mkChild({ rawAlive = true, notifyOk = true } = {}) { + const log = { kills: 0 }; + const raw = { + killed: false, + exitCode: rawAlive ? null : 0, + unref() {}, + }; + const child = { + log, + raw, + get child() { + return raw; + }, + alive: true, + kill() { + log.kills += 1; + raw.killed = true; + }, + async notify() { + if (!notifyOk) throw new Error("notify refused"); + }, + async request() { + return { ok: true }; + }, + }; + return child; +} + +/** + * A recording stand-in for `setTimeout` that also answers the + * `unref` question, which is the other half of "an unexpired escalation + * must never hold the process open". + */ +function mkTimer() { + const calls = []; + const impl = (fn, ms) => { + calls.push({ fn, ms }); + return { + unref() { + calls[calls.length - 1].unrefed = true; + }, + }; + }; + impl.calls = calls; + impl.last = () => calls[calls.length - 1]; + return impl; +} + +// =========================================================================== +// The facade under test. +// +// `setupMocks` needs a TEST context (`t.mock.module` does not exist on a +// suite context) AND its registry is per-context: a file-level `before` +// would leave every later `setupMocks(t, …)` in this file fighting an +// already-mocked `lib/acp-client.js` (ERR_INVALID_STATE). So each test +// boots the facade itself, the B5/B6 `bootFacade` shape. The facade is +// never `mock.module`d in sections 1 through 4 — only the ROUTE is +// re-imported under a fresh `?bust=N` in section 5, which is trap #2's +// actual subject. +// =========================================================================== +async function bootFacade(t) { + await setupMocks(t, {}); + return import(absPath("engine/interrupt.js")); +} + +beforeEach(() => { + // The shared RPC mock is mutated by the cases below; every case that + // cares about a refusal registers its own, so restore the default + // ("notification delivered") rather than inheriting the previous case. + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); +}); + +// =========================================================================== +// 1. The declaration and its soft-gate policy +// =========================================================================== +describe("the whole-namespace mock lists stay whole", () => { + test("FACADE_EXPORTS is exactly engine/interrupt.js's export list", async (t) => { + // Mock trap #1: `t.mock.module` replaces the whole namespace, so a + // list that drifts from the module's real exports makes every + // route-level case in section 5 fail at INSTANTIATION with a + // SyntaxError that reads like a product bug. Asserting the list + // here turns that class of mistake into one named red test. + const real = Object.keys(await import(absPath("engine/interrupt.js"))).sort(); + assert.deepEqual([...FACADE_EXPORTS].sort(), real); + }); +}); + +describe("the interrupt family's declaration and soft gate", () => { + test("both endpoints declare `interrupt` · `abortSession` as SOFT", async (t) => { + const facade = await bootFacade(t); + // One declaration for two endpoints, on purpose: #13 is not a + // different capability with a kill in it. See the module header. + assert.deepEqual(facade.INTERRUPT_ENDPOINTS["POST /api/stop"], { + capability: "interrupt", + subItem: "abortSession", + enforcement: "soft", + }); + assert.deepEqual(facade.INTERRUPT_ENDPOINTS["POST /api/protocol/cancel"], { + capability: "interrupt", + subItem: "abortSession", + enforcement: "soft", + }); + }); + + test("the DEFAULT `acp` transport reports `unregistered-transport` and NEVER throws", async (t) => { + const facade = await bootFacade(t); + // No provider is registered for `acp` (M4's job), so this is the + // pre-M3 behaviour path — and it must not be a hole in the gate. + for (const endpoint of Object.keys(facade.INTERRUPT_ENDPOINTS)) { + const d = facade.checkInterruptCapability(endpoint, ACP); + assert.equal(d.gate, "unregistered-transport", endpoint); + assert.equal(d.provider, null, endpoint); + assert.equal(d.capability, "interrupt", endpoint); + } + }); + + test("the `runtime` transport resolves the registered provider and reports `checked`", async (t) => { + const facade = await bootFacade(t); + for (const endpoint of Object.keys(facade.INTERRUPT_ENDPOINTS)) { + const d = facade.checkInterruptCapability(endpoint, RUNTIME); + assert.equal(d.gate, "checked", endpoint); + assert.equal(d.provider, "local-runtime-v2", endpoint); + } + }); + + test("resolveInterruptProvider returns null for an unregistered transport and the provider for `runtime`", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.resolveInterruptProvider(ACP), null); + assert.equal(facade.resolveInterruptProvider(RUNTIME).id, "local-runtime-v2"); + }); + + test("an unknown endpoint key is a plain Error, never a capability error", async (t) => { + const facade = await bootFacade(t); + // Caller confusion must never reach a user as 501. + let caught = null; + try { + facade.checkInterruptCapability("POST /api/not-a-member", RUNTIME); + } catch (e) { + caught = e; + } + assert.ok(caught, "an unknown key must throw"); + assert.equal(isEngineCapabilityNotSupportedError(caught), false); + assert.equal(caught.code, "unknown_interrupt_endpoint"); + }); + + test("THE 501 MACHINERY IS UNUSED BY THIS FAMILY (policy, pinned)", async (t) => { + const facade = await bootFacade(t); + // The soft gate's whole justification is that neither endpoint can + // be turned into a 501 by a declaration. There is deliberately no + // `assertInterruptCapability` export; this test fails loudly if one + // is ever added without the argument in the module header being + // rewritten first. + assert.equal(FACADE_EXPORTS.includes("assertInterruptCapability"), false); + assert.equal(typeof facade.checkInterruptCapability, "function"); + }); +}); + +// =========================================================================== +// 2. The four red lines +// =========================================================================== +describe("RED LINE 1 — the escalation is bounded", () => { + test("the bound is 5000 ms — the plan's value, taken by product call", async (t) => { + const facade = await bootFacade(t); + // KNOWN DEBT 1, resolved 2026-10-03: doc/m3-batch-plan.md transcribes + // this red line as "abort 5s 有界"; the migrated file said 2000. The + // product call took the plan's value. The number is pinned here so a + // future change to it has to be a deliberate edit. + assert.equal(facade.STOP_FORCE_KILL_MS, 5000); + }); + + test("the escalation is armed at exactly that bound, and is unref'd", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cs = mkCs(); + const cid = "b7-bound"; + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ + cs, + cid, + transport: ACP, + setTimeoutImpl: timer, + }); + assert.equal(timer.calls.length, 1, "exactly one escalation armed"); + assert.equal(timer.last().ms, facade.STOP_FORCE_KILL_MS); + assert.equal(timer.last().unrefed, true, "an unexpired escalation must not hold the process open"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("the escalation does NOT fire before the bound", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-before"; + const cs = mkCs(); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs, cid, transport: ACP, setTimeoutImpl: timer }); + assert.equal(child.log.kills, 0, "the gentle path already killed it; the escalation is idempotent"); + // A child that survived a REFUSED cancel: the immediate hard kill + // already ran, and nothing has run the timer yet — that IS the + // bound, asserted by the timer never having been called. + const stubborn = mkChild(); + const cid2 = "b7-before2"; + bus.setActiveChild(cid2, stubborn); + registerRpcMock({ cancelSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + const timer2 = mkTimer(); + await facade.applyEngineStop({ cs: mkCs(), cid: cid2, transport: ACP, setTimeoutImpl: timer2 }); + assert.equal(stubborn.log.kills, 1, "only the immediate hard kill ran"); + assert.equal(timer2.calls.length, 1, "and the escalation is armed, unrun"); + bus.clearActiveChild(cid2); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("the escalation DOES fire when the child is still alive at the bound", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-fires"; + const cs = mkCs(); + // The gentle cancel SUCCEEDS, so the immediate kill does NOT run — + // this is the "notification sent but ignored" case the bound exists + // for, and it is the case that proves the timer is armed whenever a + // child was registered, not only when one was killed. + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs, cid, transport: ACP, setTimeoutImpl: timer }); + assert.equal(child.log.kills, 0, "a delivered cancel does not kill"); + timer.last().fn(); + assert.equal(child.log.kills, 1, "the bound escalates when the child outlived the cancel"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: the escalation does NOT fire for a child that already exited", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild({ rawAlive: false }); + const cid = "b7-exited"; + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: timer }); + timer.last().fn(); + assert.equal(child.log.kills, 0, "exitCode !== null means there is nothing to kill"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: the escalation does NOT fire when the handle was already killed", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + child.raw.killed = true; + const cid = "b7-killed"; + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: timer }); + timer.last().fn(); + assert.equal(child.log.kills, 0, "rawChild.killed means the cascade already did its job"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: no escalation is armed at all when no child backs the turn", async (t) => { + const facade = await bootFacade(t); + const timer = mkTimer(); + const r = await facade.applyEngineStop({ cs: mkCs(), cid: "b7-nochild", transport: ACP, setTimeoutImpl: timer }); + assert.equal(timer.calls.length, 0, "nothing to bound when there is nothing running"); + assert.equal(r.payload.wasRunning, false); + }); + + test("the escalation reads the handle CACHED at arm time, not `child.child` at fire time", async (t) => { + const facade = await bootFacade(t); + // The runner's own stop() may null `child.child` in the window + // between arming and firing. Reading the live property then would + // silently skip the escalation the whole cascade exists for. + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-cached"; + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: timer }); + // Null the live property, exactly as a concurrent stop() would. + Object.defineProperty(child, "child", { get: () => null, configurable: true }); + timer.last().fn(); + assert.equal(child.log.kills, 1, "the cached handle is what the escalation uses"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: a throwing kill inside the escalation never escapes", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + child.kill = () => { + throw new Error("kill exploded"); + }; + const cid = "b7-throws"; + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + bus.setActiveChild(cid, child); + const timer = mkTimer(); + try { + await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: timer }); + assert.doesNotThrow(() => timer.last().fn()); + } finally { + bus.clearActiveChild(cid); + } + }); +}); + +describe("RED LINE 2 — `hardKilled` is a report about the first decision", () => { + test("a refused cancel WITH a child sets hardKilled:true", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-hk-yes"; + bus.setActiveChild(cid, child); + registerRpcMock({ cancelSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(r.payload.hardKilled, true); + assert.equal(child.log.kills, 1, "the hard path really ran"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: a delivered cancel sets hardKilled:false — nothing was killed", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-hk-no"; + bus.setActiveChild(cid, child); + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(r.payload.hardKilled, false); + assert.equal(child.log.kills, 0); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: with no child at all, hardKilled is false even though the note says hard kill", async (t) => { + const facade = await bootFacade(t); + // The note names WHY the gentle path did not happen, not what + // followed. Pre-M3 wording, preserved verbatim. + // No child AND no session id, so the gentle path is not merely + // refused — it was never available. Nothing was killed, and the + // note still says "hard kill". + const r = await facade.applyEngineStop({ + cs: mkCs({ mcodeSessionId: null }), + cid: "b7-hk-empty", + transport: ACP, + setTimeoutImpl: mkTimer(), + }); + assert.equal(r.payload.wasRunning, false); + assert.equal(r.payload.hardKilled, false); + assert.equal(r.payload.note, "hard kill (session/cancel could not be delivered)"); + }); + + test("hardKilled:true is written BEFORE the bound can fire — it never certifies the process died", async (t) => { + const facade = await bootFacade(t); + // KNOWN DEBT 3: the field cannot mean "the process is dead" and + // does not try. This test is that claim, made executable: the body + // is complete while the escalation is still pending. + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-hk-timing"; + bus.setActiveChild(cid, child); + registerRpcMock({ cancelSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + const timer = mkTimer(); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: timer }); + assert.equal(r.payload.hardKilled, true); + // The escalation has NOT run yet, and cannot have: nothing ticked. + assert.equal(timer.calls[0].ms, facade.STOP_FORCE_KILL_MS); + assert.equal(child.log.kills, 1, "only the immediate hard kill, which is what the field reports"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("REVERSE: a client state with no session id never reaches the notification at all", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-nosid"; + bus.setActiveChild(cid, child); + let calls = 0; + registerRpcMock({ + cancelSession: async () => { + calls += 1; + return { ok: true, data: { notified: true } }; + }, + }); + try { + const r = await facade.applyEngineStop({ + cs: mkCs({ mcodeSessionId: null }), + cid, + transport: ACP, + setTimeoutImpl: mkTimer(), + }); + assert.equal(calls, 0, "no session id means no notification to send"); + assert.equal(r.payload.cancelled, false); + assert.equal(r.payload.hardKilled, true, "and a registered child is killed outright"); + } finally { + bus.clearActiveChild(cid); + } + }); +}); + +describe("RED LINE 3 — `cancelled` means SENT, and a throw is not fatal", () => { + test("#13 records `cancelled:true` on a delivered notification with no reply to wait for", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-sent"; + bus.setActiveChild(cid, child); + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(r.payload.cancelled, true); + assert.equal(r.payload.note, "gentle cancel"); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("a THROWING notification degrades to the hard path instead of failing the request", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const child = mkChild(); + const cid = "b7-throw"; + bus.setActiveChild(cid, child); + registerRpcMock({ + cancelSession: async () => { + throw new Error("socket gone"); + }, + }); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(r.payload.ok, true, "the request is never failed by a broken notification"); + assert.equal(r.payload.cancelled, false); + assert.equal(r.payload.hardKilled, true); + } finally { + bus.clearActiveChild(cid); + } + }); + + test("#69 reports the same truth in its own shape, and never claims a kill", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + const r = await facade.sendEngineSessionCancel({ sessionId: SID, cid: "b7-cancel-ok", transport: ACP }); + assert.equal(r.delivered, true); + assert.deepEqual(r.payload, { ok: true, cancelled: true, data: { notified: true } }); + assert.equal("fallback" in r.payload, false, "this endpoint performs no kill and must not imply one"); + }); + + test("REVERSE: #69 on a refusal keeps its documented 'I could not do it' 200", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ cancelSession: async () => ({ ok: false, code: "no_client", error: "client offline" }) }); + const r = await facade.sendEngineSessionCancel({ sessionId: SID, cid: "b7-cancel-no", transport: ACP }); + assert.equal(r.delivered, false, "no state push may follow a refusal"); + assert.deepEqual(r.payload, { + ok: true, + cancelled: false, + warning: "client offline", + code: "no_client", + killEndpoint: "/api/stop", + }); + }); +}); + +describe("RED LINE 4 — the existing degradation is preserved", () => { + test("the zombie-claim decision fires ONLY with no child and a live claim", async (t) => { + const facade = await bootFacade(t); + // The 2026-09-20 audit escape hatch: without it, answering + // wasRunning:false strands the panel in 思考中 forever. + assert.equal(facade.stopLeftStaleClaim(false, mkCs({ running: { active: true } })), true); + assert.equal(facade.stopLeftStaleClaim(false, mkCs({ running: { active: false } })), false); + }); + + test("REVERSE: a live child means NO reset — the runner's finalize owns the terminal state", async (t) => { + const facade = await bootFacade(t); + // Resetting early would race it and could strip a `▍` cursor the + // stream is still about to rewrite. + assert.equal(facade.stopLeftStaleClaim(true, mkCs({ running: { active: true } })), false); + }); + + test("REVERSE: a missing client state is not a stale claim", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.stopLeftStaleClaim(false, null), false); + assert.equal(facade.stopLeftStaleClaim(false, undefined), false); + assert.equal(facade.stopLeftStaleClaim(false, {}), false); + }); + + test("the facade reports the decision; it never mutates the client state", async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const cid = "b7-claim"; + const cs = mkCs({ running: { active: true, prompt: "live" } }); + bus.setActiveChild(cid, null); + try { + const r = await facade.applyEngineStop({ cs, cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(r.claimStale, true); + assert.equal(cs.running.active, true, "the reset is the ROUTE's — its helper is shared with handleSend"); + assert.equal(cs.running.prompt, "live"); + } finally { + bus.clearActiveChild(cid); + } + }); +}); + +// =========================================================================== +// 3. The byte-for-byte wire shapes +// =========================================================================== +describe("the wire shapes, byte for byte", () => { + // Key ORDER is part of the contract: these bodies are compared as + // strings, not as parsed objects, so a reordering that changes no + // field still fails here. + const CASES = [ + { + name: "#13 gentle cancel", + expected: + '{"ok":true,"wasRunning":true,"cancelled":true,"hardKilled":false,"note":"gentle cancel"}', + }, + { + name: "#13 hard kill", + expected: + '{"ok":true,"wasRunning":true,"cancelled":false,"hardKilled":true,"note":"hard kill (session/cancel could not be delivered)"}', + }, + { + name: "#13 nothing running (the note is unchanged here too)", + expected: + '{"ok":true,"wasRunning":false,"cancelled":false,"hardKilled":false,"note":"hard kill (session/cancel could not be delivered)"}', + }, + ]; + + for (const c of CASES) { + test(`${c.name} — the exact body string, key order included`, async (t) => { + const facade = await bootFacade(t); + const bus = await import(absPath("lib/state-bus.js")); + const cid = `b7-wire-${c.name}`; + const withChild = c.expected.includes('"wasRunning":true'); + const delivered = c.expected.includes('"cancelled":true'); + if (withChild) bus.setActiveChild(cid, mkChild()); + registerRpcMock({ + cancelSession: async () => + delivered + ? { ok: true, data: { notified: true } } + : { ok: false, code: "no_client", error: "offline" }, + }); + try { + const r = await facade.applyEngineStop({ cs: mkCs(), cid, transport: ACP, setTimeoutImpl: mkTimer() }); + assert.equal(JSON.stringify(r.payload), c.expected); + } finally { + bus.clearActiveChild(cid); + } + }); + } + + test("the three #13 bodies reach the client with status 200 and the route's own Content-Type", async (t) => { + // Same table, driven through the ROUTE this time, so the status + // line and the header are asserted from the code that writes them + // rather than from a hand-rolled stand-in. + await setupMocks(t, {}); + const namedExports = {}; + for (const name of FACADE_EXPORTS) { + namedExports[name] = () => { + throw new Error(`B7 test called engine/interrupt.js#${name}, which this case did not stub`); + }; + } + let body = null; + namedExports.applyEngineStop = async () => ({ + payload: { + ok: true, + wasRunning: true, + cancelled: false, + hardKilled: true, + note: "hard kill (session/cancel could not be delivered)", + }, + claimStale: false, + gate: {}, + transport: "acp", + }); + t.mock.module(absPath("engine/interrupt.js"), { namedExports }); + const route = await import(`${absPath("routes/chat.js")}?bust=${bust++}`); + const res = mkRes(); + await route.handleStop({ method: "POST", url: "/api/stop" }, res, { cs: mkCs(), cid: "tab-wire" }); + const seen = lastResponse(res); + assert.equal(seen.status, 200); + assert.equal(seen.headers["Content-Type"], "application/json; charset=utf-8"); + body = seen.body; + assert.equal( + body, + '{"ok":true,"wasRunning":true,"cancelled":false,"hardKilled":true,"note":"hard kill (session/cancel could not be delivered)"}', + ); + }); + + test("#69 success body string", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ cancelSession: async () => ({ ok: true, data: { notified: true } }) }); + const r = await facade.sendEngineSessionCancel({ sessionId: SID, cid: "b7-w69-ok", transport: ACP }); + assert.equal(JSON.stringify(r.payload), '{"ok":true,"cancelled":true,"data":{"notified":true}}'); + }); + + test("#69 refusal body string", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ cancelSession: async () => ({ ok: false, code: "no_client", error: "client offline" }) }); + const r = await facade.sendEngineSessionCancel({ sessionId: SID, cid: "b7-w69-no", transport: ACP }); + assert.equal( + JSON.stringify(r.payload), + '{"ok":true,"cancelled":false,"warning":"client offline","code":"no_client","killEndpoint":"/api/stop"}', + ); + }); + + test("a 400 for a missing sessionId stays the ROUTE's, in the route's own words", async (t) => { + await setupMocks(t, {}); + const route = await import(`${absPath("routes/protocol.js")}?bust=${bust++}`); + const res = mkRes(); + await route.handleCancel(jsonReq({}), res, { cs: mkCs(), cid: "b7-400" }); + const seen = lastResponse(res); + assert.equal(seen.status, 400); + assert.equal(seen.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(seen.body, '{"ok":false,"error":"sessionId required"}'); + }); +}); + +// =========================================================================== +// 4. The pure derivations +// =========================================================================== +describe("the pure derivations", () => { + test("stopLeftStaleClaim is a total function over (wasRunning, cs)", async (t) => { + const facade = await bootFacade(t); + const table = [ + [false, mkCs({ running: { active: true } }), true], + [false, mkCs({ running: { active: false } }), false], + [true, mkCs({ running: { active: true } }), false], + [false, {}, false], + [false, null, false], + ]; + for (const [wasRunning, cs, expected] of table) { + assert.equal(facade.stopLeftStaleClaim(wasRunning, cs), expected, JSON.stringify(wasRunning)); + } + }); +}); + +// =========================================================================== +// 5. The route, with the proof that the facade mock actually took +// =========================================================================== +describe("routes/chat.js#handleStop", () => { + beforeEach(() => { + bust += 0; + }); + + function mockFacade(t, impls) { + const namedExports = {}; + for (const name of FACADE_EXPORTS) { + namedExports[name] = () => { + throw new Error(`B7 test called engine/interrupt.js#${name}, which this case did not stub`); + }; + } + Object.assign(namedExports, impls); + t.mock.module(absPath("engine/interrupt.js"), { namedExports }); + } + const loadRoute = async () => import(`${absPath("routes/chat.js")}?bust=${bust++}`); + + test("the route writes the facade's body verbatim and does not rebuild it", async (t) => { + await setupMocks(t, {}); + const PAYLOAD = { + ok: true, + wasRunning: true, + cancelled: false, + hardKilled: true, + note: "hard kill (session/cancel could not be delivered)", + }; + let seenArgs = null; + mockFacade(t, { + applyEngineStop: async (args) => { + seenArgs = args; + return { payload: PAYLOAD, claimStale: false, gate: {}, transport: "acp" }; + }, + }); + const route = await loadRoute(); + const cs = mkCs({ running: { active: true, prompt: "live" } }); + const res = mkRes(); + await route.handleStop({ method: "POST", url: "/api/stop" }, res, { cs, cid: "tab-1" }); + assert.deepEqual(seenArgs && { cs: seenArgs.cs, cid: seenArgs.cid }, { cs, cid: "tab-1" }); + const seen = lastResponse(res); + assert.equal(seen.status, 200); + assert.equal(seen.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(seen.body, JSON.stringify(PAYLOAD)); + assert.equal(cs.running.prompt, "live", "claimStale:false means the route touches nothing"); + }); + + test("the route applies the claim reset ONLY when the facade says the claim went stale", async (t) => { + await setupMocks(t, {}); + mockFacade(t, { + applyEngineStop: async () => ({ + payload: { ok: true, wasRunning: false, cancelled: false, hardKilled: false, note: "x" }, + claimStale: true, + gate: {}, + transport: "acp", + }), + }); + const route = await loadRoute(); + const cs = mkCs({ running: { active: true, prompt: "live" } }); + cs.chat = ["● partial ▍"]; + const res = mkRes(); + await route.handleStop({ method: "POST", url: "/api/stop" }, res, { cs, cid: "tab-1" }); + assert.equal(cs.running.active, false, "the zombie claim is cleared"); + assert.equal(cs.context.thinkingStatus, "Idle"); + assert.deepEqual(cs.chat, ["● partial"], "the streaming cursor is stripped, exactly as handleSend's path does it"); + }); + + test("PROOF: a marker error from the facade escapes the route", async (t) => { + // Without a fresh `?bust=` re-import, `mock.module` would leave the + // route holding the PREVIOUS test's live binding, the marker would + // never be thrown, and this assertion would fail — which is the + // point: it is the only assertion in this section that cannot pass + // by accident. + await setupMocks(t, {}); + const marker = new Error("B7-MOCK-WAS-NOT-HONOURED"); + mockFacade(t, { + applyEngineStop: async () => { + throw marker; + }, + }); + const route = await loadRoute(); + let caught = null; + try { + await route.handleStop({ method: "POST", url: "/api/stop" }, mkRes(), { + cs: mkCs(), + cid: "tab-1", + }); + } catch (err) { + caught = err; + } + assert.ok(caught, "the route swallowed the facade error — either the mock did not take, or the route grew a catch"); + assert.equal(caught, marker, "the error is the mock's, by identity"); + }); +}); + +describe("routes/protocol.js#handleCancel", () => { + function mockFacade(t, impls) { + const namedExports = {}; + for (const name of FACADE_EXPORTS) { + namedExports[name] = () => { + throw new Error(`B7 test called engine/interrupt.js#${name}, which this case did not stub`); + }; + } + Object.assign(namedExports, impls); + t.mock.module(absPath("engine/interrupt.js"), { namedExports }); + } + const loadRoute = async () => import(`${absPath("routes/protocol.js")}?bust=${bust++}`); + + test("the route pushes state only on a DELIVERED notification", async (t) => { + await setupMocks(t, {}); + let delivered; + mockFacade(t, { + sendEngineSessionCancel: async () => ({ + payload: { ok: true, cancelled: false, warning: "offline", code: "no_client", killEndpoint: "/api/stop" }, + delivered: false, + gate: {}, + transport: "acp", + }), + }); + delivered = false; + const route = await loadRoute(); + const bus = await import(absPath("lib/state-bus.js")); + const res = mkRes(); + // The route's push goes through the REAL state bus; the state frame + // only lands if a client is registered for the cid, so register one + // and observe that the refusal did not reset it. + const cs = mkCs(); + bus.clients.set("tab-cancel", cs); + try { + await route.handleCancel(jsonReq({ sessionId: SID }), res, { cs, cid: "tab-cancel" }); + const seen = lastResponse(res); + assert.equal(seen.status, 200); + assert.equal( + seen.body, + '{"ok":true,"cancelled":false,"warning":"offline","code":"no_client","killEndpoint":"/api/stop"}', + ); + assert.equal(cs.running.active, false, "a refusal must not re-assert a run claim"); + } finally { + bus.clients.delete("tab-cancel"); + } + }); + + test("PROOF: a marker error from the facade escapes the route", async (t) => { + await setupMocks(t, {}); + const marker = new Error("B7-CANCEL-MOCK-WAS-NOT-HONOURED"); + mockFacade(t, { + sendEngineSessionCancel: async () => { + throw marker; + }, + }); + const route = await loadRoute(); + let caught = null; + try { + await route.handleCancel(jsonReq({ sessionId: SID }), mkRes(), { cs: mkCs(), cid: "tab-1" }); + } catch (err) { + caught = err; + } + assert.ok(caught, "either the mock did not take, or the route grew a catch"); + assert.equal(caught, marker, "the error is the mock's, by identity"); + }); +}); diff --git a/packages/webui/test/lib/engine/session-load.test.js b/packages/webui/test/lib/engine/session-load.test.js new file mode 100644 index 000000000..73e3d0223 --- /dev/null +++ b/packages/webui/test/lib/engine/session-load.test.js @@ -0,0 +1,816 @@ +// webui/test/lib/engine/session-load.test.js +// +// M3-B7 (part 2): the LOAD and ACTIVATE family's engine facade — #70 +// POST /api/protocol/load-session and #71 +// POST /api/protocol/activate-session. +// +// Sections are ordered by how much user-visible damage a regression in +// each one does, not by which module the function came from: +// +// 1. THE DECLARATION AND ITS SPLIT GATE POLICY. #70 gates HARD +// (`sessionCrud` · `loadSession`), #71 gates SOFT. The soft half is +// the consequential judgement call: hard-gating #71 would be +// silently answering the activate-semantic-collapse question the +// plan leaves open, so the suite proves the soft gate reports +// `capability-absent` for a provider that declares nothing — the +// branch the real registry cannot currently reach — and that the +// endpoint still answers. +// 2. THE FOUR RED LINES. The activate response SHAPE (the collapse +// decision this batch must not take), the existing status +// degradations, the sidebar entry being downstream of the engine's +// answer, and the activate ordering. One named test per line, plus +// the NEGATIVE half of each. +// 3. THE BYTE-FOR-BYTE WIRE SHAPES. +// 4. THE PURE DERIVATIONS — the three status mappers and the wire-code +// rewrite — table-driven, including rows no fixture reaches. +// 5. THE ROUTES, with the proof that the facade mock actually took AND +// the proof that #70's capability error ESCAPES the route (that +// escape is the mechanism the router's central 501 mapping +// depends on). +// +// Two module-mock traps apply here exactly as they did in B3 through B6: +// `t.mock.module` REPLACES THE WHOLE NAMESPACE (so every facade mock +// goes through `mockAll()`, which fills un-stubbed names with a +// THROWER), and it re-evaluates only the MOCKED specifier (so every +// route re-import in section 5 carries a fresh `?bust=N`, and section 5 +// ends with marker controls that prove it). `setupMocks` needs a TEST +// context and its registry is per-context, so every test boots the +// facade itself — the B5/B6 `bootFacade` shape — rather than sharing a +// file-level `before`. + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { + setupMocks, + absPath, + registerRpcMock, + registerSessionsStore, + getSessionsStore, +} from "../../helpers/_setup.js"; +// Type discrimination goes through the exported predicate, never +// `err.name`: `name` is a writable instance property, so one stray +// upstream assignment would turn a 501 back into a soft failure — a +// failure mode that reads as a passing test. +const { isEngineCapabilityNotSupportedError } = await import( + "../../../server/engine/errors.js" +); + +const RUNTIME = "runtime"; +const ACP = "acp"; + +/** A syntactically valid engine sid. */ +const SID_A = "mvs_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SID_B = "mvs_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +let bust = 0; + +/** Every name `engine/session-load.js` exports. The namespace, not a subset. */ +const FACADE_EXPORTS = [ + "SESSION_LOAD_ENDPOINTS", + "activateEngineSession", + "activateFailureStatus", + "assertSessionLoadCapability", + "checkSessionActivateCapability", + "loadEngineSession", + "loadFailureStatus", + "loadFailureWireCode", + "resolveSessionLoadProvider", +]; + +/** A JSON request body the real `lib/read-json.js` can consume. */ +function jsonReq(body) { + return Readable.from([Buffer.from(JSON.stringify(body), "utf8")]); +} + +/** A minimal `ServerResponse` stand-in that records what was written. */ +function mkRes() { + const written = []; + return { + written, + writeHead(status, headers) { + written.push({ status, headers }); + return this; + }, + end(body) { + written.push({ body }); + return this; + }, + }; +} + +/** The last `writeHead` + `end` pair, as one observation. */ +function lastResponse(res) { + const head = res.written[res.written.length - 2]; + const tail = res.written[res.written.length - 1]; + assert.ok(head && head.status !== undefined, "the handler never wrote a head"); + return { status: head.status, headers: head.headers, body: tail ? tail.body : undefined }; +} + +/** A client state carrying only what this family reads or writes. */ +function mkCs(overrides = {}) { + return { + mcodeSessionId: null, + chat: [], + context: { tokens: 5, used: 6, percent: 7, thinkingStatus: "Busy" }, + running: { active: true, prompt: "live" }, + workspace: { dir: "/ws-A", branch: null, tree: null }, + ...overrides, + }; +} + +/** + * A whole-namespace mock in which every export THROWS. + * + * The names come from the module's SOURCE, never from evaluating it: + * see the resetContext-ordering case for why evaluating + * `lib/mcode-rpc.js` is not an option here. A stale list is not a + * silent failure either — an export the mock omits is `undefined`, and + * the consumer fails loudly at instantiation. + */ +function throwingNamespace(fileUrl) { + const src = readFileSync(fileURLToPath(fileUrl), "utf8"); + const names = new Set(); + for (const m of src.matchAll(/^export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)) names.add(m[1]); + for (const m of src.matchAll(/^export\s+(?:const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm)) names.add(m[1]); + for (const m of src.matchAll(/^export\s*\{([^}]*)\}/gm)) { + for (const part of m[1].split(",")) { + const name = part.trim().split(/\s+as\s+/).pop().trim(); + if (name) names.add(name); + } + } + assert.ok(names.size > 0, `no export names parsed out of ${fileUrl}`); + const out = {}; + for (const name of names) { + out[name] = () => { + throw new Error(`B7 test called ${fileUrl}#${name}, which this case did not stub`); + }; + } + return out; +} + +/** Boot the facade with the shared webui module surface mocked. */ +async function bootFacade(t) { + await setupMocks(t, {}); + return import(absPath("engine/session-load.js")); +} + +/** + * Boot the facade against a PROVIDER THAT DECLARES NOTHING for + * `sessionCrud`. + * + * The registry's two providers both declare `sessionCrud: full`, so the + * soft gate's `capability-absent` branch is otherwise unreachable in a + * test — and an unreachable branch is an unpinned one. `engine/index.js` + * is mocked here for its WHOLE namespace (trap #1); `session-load.js` is + * then imported FRESH so it picks the mock up as its live binding. + */ +async function bootFacadeWithProvider(t, capabilities) { + await setupMocks(t, {}); + const namedExports = {}; + for (const name of [ + "ENGINE_CAPABILITY_KEYS", + "DEFAULT_ENGINE_PROVIDER_ID", + "getEngineProvider", + "listEngineProviderIds", + "assertEngineCapability", + "summarizeUnavailableCapabilities", + "validateEngineCapabilities", + "getEngineCatalogueHost", + "EngineCapabilityNotSupportedError", + "engineCapabilityHttpResponse", + "isEngineCapabilityNotSupportedError", + ]) { + namedExports[name] = () => { + throw new Error(`B7 test called engine/index.js#${name}, which this case did not stub`); + }; + } + Object.assign(namedExports, { + DEFAULT_ENGINE_PROVIDER_ID: "local-runtime-v2", + getEngineProvider: (id = "local-runtime-v2") => ({ + id, + transport: "runtime", + capabilities, + }), + }); + t.mock.module(absPath("engine/index.js"), { namedExports }); + return import(`${absPath("engine/session-load.js")}?provider=${bust++}`); +} + +/** A declaration in which `sessionCrud` is absent entirely. */ +const NO_SESSION_CRUD = { + sessionCrud: { level: "none", reason: "test: interface-absent" }, + interrupt: { level: "full" }, +}; + +beforeEach(() => { + // Restore the shared mocks every case relies on as a baseline. + registerRpcMock({ + loadSession: async () => ({ ok: true, data: { sessionId: SID_A } }), + activateSession: async () => ({ ok: true, data: {} }), + }); + registerSessionsStore({ initial: [] }); +}); + +// =========================================================================== +// 1. The declaration and its split gate policy +// =========================================================================== +describe("the whole-namespace mock lists stay whole", () => { + test("FACADE_EXPORTS is exactly engine/session-load.js's export list", async (t) => { + // Mock trap #1: `t.mock.module` replaces the whole namespace, so a + // list that drifts from the module's real exports makes every + // route-level case in section 5 fail at INSTANTIATION with a + // SyntaxError that reads like a product bug. Asserting the list + // here turns that class of mistake into one named red test. + const real = Object.keys(await import(absPath("engine/session-load.js"))).sort(); + assert.deepEqual([...FACADE_EXPORTS].sort(), real); + }); +}); + +describe("the load/activate family's declaration and split gate", () => { + test("#70 declares `sessionCrud` · `loadSession` as HARD", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual(facade.SESSION_LOAD_ENDPOINTS["POST /api/protocol/load-session"], { + capability: "sessionCrud", + subItem: "loadSession", + enforcement: "hard", + }); + }); + + test("#71 declares `sessionCrud` · `activateSession` as SOFT", async (t) => { + const facade = await bootFacade(t); + assert.deepEqual(facade.SESSION_LOAD_ENDPOINTS["POST /api/protocol/activate-session"], { + capability: "sessionCrud", + subItem: "activateSession", + enforcement: "soft", + }); + }); + + test("the DEFAULT `acp` transport reports `unregistered-transport` for both", async (t) => { + const facade = await bootFacade(t); + for (const endpoint of Object.keys(facade.SESSION_LOAD_ENDPOINTS)) { + const hard = facade.assertSessionLoadCapability(endpoint, ACP); + assert.equal(hard.gate, "unregistered-transport", endpoint); + assert.equal(hard.provider, null, endpoint); + const soft = facade.checkSessionActivateCapability(endpoint, ACP); + assert.equal(soft.gate, "unregistered-transport", endpoint); + } + }); + + test("the `runtime` transport resolves the registered provider and reports `checked`", async (t) => { + const facade = await bootFacade(t); + assert.equal( + facade.assertSessionLoadCapability("POST /api/protocol/load-session", RUNTIME).gate, + "checked", + ); + assert.equal( + facade.checkSessionActivateCapability("POST /api/protocol/activate-session", RUNTIME).gate, + "checked", + ); + assert.equal(facade.resolveSessionLoadProvider(RUNTIME).id, "local-runtime-v2"); + assert.equal(facade.resolveSessionLoadProvider(ACP), null); + }); + + test("an unknown endpoint key is a plain Error on BOTH gates, never a capability error", async (t) => { + const facade = await bootFacade(t); + for (const fn of [facade.assertSessionLoadCapability, facade.checkSessionActivateCapability]) { + let caught = null; + try { + fn("POST /api/not-a-member", RUNTIME); + } catch (e) { + caught = e; + } + assert.ok(caught, "an unknown key must throw"); + assert.equal(isEngineCapabilityNotSupportedError(caught), false); + assert.equal(caught.code, "unknown_session_load_endpoint"); + } + }); + + test("the HARD gate throws EngineCapabilityNotSupportedError for a provider that declares nothing", async (t) => { + const facade = await bootFacadeWithProvider(t, NO_SESSION_CRUD); + let caught = null; + try { + facade.assertSessionLoadCapability("POST /api/protocol/load-session", RUNTIME); + } catch (e) { + caught = e; + } + assert.ok(isEngineCapabilityNotSupportedError(caught), "the hard gate must throw the structured error"); + assert.equal(caught.capability, "sessionCrud"); + assert.equal(caught.provider, "local-runtime-v2"); + }); + + test("the SOFT gate REPORTS `capability-absent` for the same provider and never throws", async (t) => { + const facade = await bootFacadeWithProvider(t, NO_SESSION_CRUD); + const d = facade.checkSessionActivateCapability("POST /api/protocol/activate-session", RUNTIME); + assert.equal(d.gate, "capability-absent"); + assert.equal(d.provider, "local-runtime-v2"); + assert.equal(d.enforcement, "soft"); + }); +}); + +// =========================================================================== +// 2. The four red lines +// =========================================================================== +describe("RED LINE 1 — the activate response shape is NOT the collapse decision", () => { + test("the success body is byte-for-byte the pre-M3 shape", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ activateSession: async () => ({ ok: true, data: { activated: true } }) }); + const r = await facade.activateEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(r.statusHint, 200); + assert.equal( + JSON.stringify(r.payload), + `{"ok":true,"activeSessionId":"${SID_A}","data":{"activated":true}}`, + ); + // Key ORDER included: the field order is part of the pinned string. + assert.deepEqual(Object.keys(r.payload), ["ok", "activeSessionId", "data"]); + }); + + test("REVERSE: a provider with NO activate surface still gets that same 200 — the gate is soft", async (t) => { + // This is the load-bearing half of the decision NOT being taken. A + // hard gate would answer 501 here, which is one of the two + // branches KNOWN DEBT 1 costs — and choosing it silently, from a + // capability table, with no frontend work, is exactly what this + // batch is not entitled to do. + const facade = await bootFacadeWithProvider(t, NO_SESSION_CRUD); + registerRpcMock({ activateSession: async () => ({ ok: true, data: { activated: true } }) }); + const r = await facade.activateEngineSession({ sessionId: SID_A, cs: mkCs(), transport: RUNTIME }); + assert.equal(r.gate.gate, "capability-absent"); + assert.equal(r.statusHint, 200, "soft means the pre-M3 answer survives"); + assert.equal( + JSON.stringify(r.payload), + `{"ok":true,"activeSessionId":"${SID_A}","data":{"activated":true}}`, + ); + }); + + test("the 501 this route can still answer is the PRE-EXISTING one, from `unsupported`", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ activateSession: async () => ({ ok: false, code: "unsupported", error: "no" }) }); + const r = await facade.activateEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(r.statusHint, 501); + assert.equal(r.payload.code, "unsupported", "the RPC code, NOT the engine-gate body — two different 501s"); + assert.equal("capability" in r.payload, false); + assert.equal("provider" in r.payload, false); + }); +}); + +describe("RED LINE 2 — the existing status degradations are preserved", () => { + test("#70 answers 500 for `unsupported` — deliberately NOT 501", async (t) => { + // The asymmetry with set-mode is pinned by the pre-existing suite + // too; unifying them would be a behaviour change to two endpoints. + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: false, code: "unsupported", error: "no" }) }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(r.statusHint, 500); + }); + + test("#70 rewrites the engine's Resource-not-found code for the frontend", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ + loadSession: async () => ({ ok: false, code: "resource_not_found", error: "Resource not found" }), + }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(r.statusHint, 404); + assert.equal(r.payload.code, "session_not_found"); + }); + + test("REVERSE: a failure with NO code drops the key entirely, as it always did", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: false, error: "boom" }) }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(r.statusHint, 500); + assert.equal(r.payload.code, undefined, "no code was invented"); + assert.equal(JSON.stringify(r.payload), '{"ok":false,"error":"boom"}'); + }); + + test("a failed #71 leaves the client state completely untouched", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ activateSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + const cs = mkCs({ mcodeSessionId: SID_B }); + const r = await facade.activateEngineSession({ sessionId: SID_A, cs, transport: ACP }); + assert.equal(r.statusHint, 503); + assert.equal(cs.mcodeSessionId, SID_B, "a refused activate must not rebind the client"); + assert.equal(cs.context.tokens, 5, "and must not reset the context"); + }); +}); + +describe("RED LINE 3 — the sidebar entry is DOWNSTREAM of the engine's answer", () => { + test("a FAILED load creates no entry at all, even when one was requested", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + const r = await facade.loadEngineSession({ + sessionId: SID_A, + createWebuiEntry: true, + cs: mkCs(), + transport: ACP, + }); + assert.equal(r.statusHint, 503); + assert.equal(getSessionsStore().length, 0, "no entry for a session the engine never loaded"); + assert.equal("webuiEntry" in r.payload, false, "the failure body has no such key"); + }); + + test("a REFUSED capability gate never reaches the engine at all", async (t) => { + const facade = await bootFacadeWithProvider(t, NO_SESSION_CRUD); + let engineCalls = 0; + registerRpcMock({ + loadSession: async () => { + engineCalls += 1; + return { ok: true, data: {} }; + }, + }); + await assert.rejects( + facade.loadEngineSession({ sessionId: SID_A, createWebuiEntry: true, cs: mkCs(), transport: RUNTIME }), + (e) => isEngineCapabilityNotSupportedError(e), + ); + assert.equal(engineCalls, 0, "the gate runs BEFORE the dispatch — that is the whole point of it"); + assert.equal(getSessionsStore().length, 0); + }); + + test("a successful load with `createWebuiEntry` writes exactly one record", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: true, data: {} }) }); + let n = 0; + const r = await facade.loadEngineSession({ + sessionId: SID_A, + createWebuiEntry: true, + cs: mkCs(), + transport: ACP, + newId: () => `webui-${++n}`, + }); + assert.equal(r.statusHint, 200); + assert.equal(getSessionsStore().length, 1); + const entry = getSessionsStore()[0]; + assert.equal(entry.id, "webui-1"); + assert.equal(entry.mcodeSessionId, SID_A); + assert.equal(entry.title, "Mcode session"); + assert.equal(entry.workspace, "/ws-A", "falls back to the client's workspace when no cwd was given"); + assert.deepEqual(entry.chat, []); + }); + + test("REVERSE: without `createWebuiEntry` the store is untouched and `webuiEntry` is null", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: true, data: {} }) }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(getSessionsStore().length, 0); + assert.equal(r.payload.webuiEntry, null, "a literal null, not an omitted key — the wire shape pins it"); + }); + + test("the entry is IDEMPOTENT on `mcodeSessionId`: a second call reuses the record", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: true, data: {} }) }); + let n = 0; + const opts = { + sessionId: SID_A, + createWebuiEntry: true, + cs: mkCs(), + transport: ACP, + newId: () => `webui-${++n}`, + }; + const first = await facade.loadEngineSession(opts); + const second = await facade.loadEngineSession(opts); + assert.equal(first.payload.webuiEntry.id, "webui-1"); + assert.equal(second.payload.webuiEntry.id, "webui-1", "no duplicate sidebar entry for one conversation"); + assert.equal(getSessionsStore().length, 1); + }); + + test("an explicit cwd wins over the client's workspace, in BOTH places", async (t) => { + const facade = await bootFacade(t); + let seenCwd = null; + registerRpcMock({ + loadSession: async (_sid, cwd) => { + seenCwd = cwd; + return { ok: true, data: {} }; + }, + }); + let n = 0; + await facade.loadEngineSession({ + sessionId: SID_A, + cwd: "/ws-explicit", + createWebuiEntry: true, + cs: mkCs(), + transport: ACP, + newId: () => `webui-${++n}`, + }); + assert.equal(seenCwd, "/ws-explicit", "the engine is told the explicit cwd"); + assert.equal(getSessionsStore()[0].workspace, "/ws-explicit", "and the record is stamped with it too"); + }); + + test("REVERSE: with no client state, `createWebuiEntry` writes nothing", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: true, data: {} }) }); + const r = await facade.loadEngineSession({ sessionId: SID_A, createWebuiEntry: true, transport: ACP }); + assert.equal(r.payload.webuiEntry, null); + assert.equal(getSessionsStore().length, 0); + }); +}); + +describe("RED LINE 4 — the activate order is `mcodeSessionId` FIRST, `resetContext` SECOND", () => { + test("`resetContext` observes the NEW session id", async (t) => { + // This case does NOT use setupMocks, for two reasons that are both + // load-bearing: + // + // 1. setupMocks registers its own `lib/sessions.js` mock, and + // `t.mock.module` refuses a second registration of the same + // specifier on one tracker (ERR_INVALID_STATE) — so the + // instrumented store this test needs would be unreachable. + // 2. Evaluating the REAL `lib/mcode-rpc.js` to enumerate its + // exports pulls in the real `lib/acp-client.js`, which starts + // the ACP singleton child and leaves the test process unable + // to exit. An earlier draft of this case did exactly that; the + // export names are read from the SOURCE instead, which is both + // cheaper and free of side effects. + // + // Trap #1 still applies to both mocks: `mock.module` replaces the + // whole namespace, so every name below is filled with a thrower and + // only the three this case needs are overridden. + const rpcExports = throwingNamespace(absPath("lib/mcode-rpc.js")); + const sessionExports = throwingNamespace(absPath("lib/sessions.js")); + let seenSidAtReset = "NOT-CALLED"; + Object.assign(sessionExports, { + loadSessions: () => [], + saveSessions: () => {}, + resetContext: (cs) => { + seenSidAtReset = cs.mcodeSessionId; + cs.context.tokens = 0; + }, + }); + Object.assign(rpcExports, { + activateSession: async () => ({ ok: true, data: {} }), + }); + t.mock.module(absPath("lib/mcode-rpc.js"), { namedExports: rpcExports }); + t.mock.module(absPath("lib/sessions.js"), { namedExports: sessionExports }); + const facade = await import(absPath("engine/session-load.js")); + const cs = mkCs({ mcodeSessionId: SID_B }); + await facade.activateEngineSession({ sessionId: SID_A, cs, transport: ACP }); + assert.equal(seenSidAtReset, SID_A, "reversing the two leaves the panel describing the session just left"); + assert.equal(cs.mcodeSessionId, SID_A); + assert.equal(cs.context.tokens, 0, "and the reset really ran"); + }); +}); + +// =========================================================================== +// 3. The byte-for-byte wire shapes +// =========================================================================== +describe("the wire shapes, byte for byte", () => { + test("#70 success without an entry", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ loadSession: async () => ({ ok: true, data: { ignored: true } }) }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(JSON.stringify(r.payload), `{"ok":true,"sessionId":"${SID_A}","webuiEntry":null}`); + }); + + test("#70 failure body, with the rewritten code", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ + loadSession: async () => ({ ok: false, code: "resource_not_found", error: "Resource not found" }), + }); + const r = await facade.loadEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(JSON.stringify(r.payload), '{"ok":false,"error":"Resource not found","code":"session_not_found"}'); + }); + + test("#71 failure body", async (t) => { + const facade = await bootFacade(t); + registerRpcMock({ activateSession: async () => ({ ok: false, code: "no_client", error: "offline" }) }); + const r = await facade.activateEngineSession({ sessionId: SID_A, cs: mkCs(), transport: ACP }); + assert.equal(JSON.stringify(r.payload), '{"ok":false,"error":"offline","code":"no_client"}'); + }); + + test("both 400s stay the ROUTE's, in the route's own words", async (t) => { + await setupMocks(t, {}); + const route = await import(`${absPath("routes/protocol.js")}?bust=${bust++}`); + for (const handler of [route.handleLoadSession, route.handleActivateSession]) { + const res = mkRes(); + await handler(jsonReq({}), res, { cs: mkCs(), cid: "cid-1" }); + const seen = lastResponse(res); + assert.equal(seen.status, 400); + assert.equal(seen.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(seen.body, '{"ok":false,"error":"sessionId required"}'); + } + }); +}); + +// =========================================================================== +// 4. The pure derivations +// =========================================================================== +describe("the pure derivations", () => { + test("loadFailureStatus — the whole table, including rows no fixture reaches", async (t) => { + const facade = await bootFacade(t); + const table = [ + ["no_client", 503], + ["session_not_found", 404], + ["resource_not_found", 404], + ["invalid_params", 404], + ["unsupported", 500], + ["rpc_error", 500], + // The numeric JSON-RPC form does NOT match `/not.found|invalid/`, + // so it falls through to 500. Pre-M3 behaviour, pinned as-is — + // see KNOWN DEBT 4 in the module header. + ["-32002", 500], + [undefined, 500], + ["", 500], + ]; + for (const [code, expected] of table) { + assert.equal(facade.loadFailureStatus(code), expected, String(code)); + } + }); + + test("activateFailureStatus — `unsupported` is the one row that differs from load's", async (t) => { + const facade = await bootFacade(t); + const table = [ + ["unsupported", 501], + ["no_client", 503], + ["resource_not_found", 404], + ["invalid_params", 404], + ["rpc_error", 500], + ["-32002", 500], + [undefined, 500], + ]; + for (const [code, expected] of table) { + assert.equal(facade.activateFailureStatus(code), expected, String(code)); + } + assert.notEqual( + facade.activateFailureStatus("unsupported"), + facade.loadFailureStatus("unsupported"), + "the asymmetry is the endpoint's documented contract, not an accident", + ); + }); + + test("loadFailureWireCode — rewritten, passed through, or absent", async (t) => { + const facade = await bootFacade(t); + assert.equal(facade.loadFailureWireCode("resource_not_found"), "session_not_found"); + assert.equal(facade.loadFailureWireCode("no_client"), "no_client"); + assert.equal(facade.loadFailureWireCode(undefined), undefined); + // The numeric JSON-RPC code passes through unchanged — the rewrite + // only ever matched the string form. Pinned because a future edit + // that "fixes" it is a wire change, not a refactor. + assert.equal(facade.loadFailureWireCode("-32004"), "-32004"); + }); +}); + +// =========================================================================== +// 5. The routes, with the proof that the facade mock actually took +// =========================================================================== +describe("routes/protocol.js — load-session and activate-session", () => { + function mockFacade(t, impls) { + const namedExports = {}; + for (const name of FACADE_EXPORTS) { + namedExports[name] = () => { + throw new Error(`B7 test called engine/session-load.js#${name}, which this case did not stub`); + }; + } + Object.assign(namedExports, impls); + t.mock.module(absPath("engine/session-load.js"), { namedExports }); + } + const loadRoute = async () => import(`${absPath("routes/protocol.js")}?bust=${bust++}`); + + test("#70: the route writes the facade's status and body, and pushes state on success", async (t) => { + await setupMocks(t, {}); + let seenArgs = null; + mockFacade(t, { + loadEngineSession: async (args) => { + seenArgs = args; + return { + payload: { ok: true, sessionId: "mvs_x", webuiEntry: null }, + statusHint: 200, + gate: {}, + transport: "acp", + }; + }, + }); + const route = await loadRoute(); + const cs = mkCs(); + const res = mkRes(); + await route.handleLoadSession( + jsonReq({ sessionId: "mvs_x", cwd: "/ws-A", createWebuiEntry: true }), + res, + { cs, cid: "tab-1" }, + ); + assert.deepEqual(seenArgs, { sessionId: "mvs_x", cwd: "/ws-A", createWebuiEntry: true, cs }); + const seen = lastResponse(res); + assert.equal(seen.status, 200); + assert.equal(seen.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(seen.body, '{"ok":true,"sessionId":"mvs_x","webuiEntry":null}'); + }); + + test("#70: a failure status is written WITHOUT pushing state", async (t) => { + await setupMocks(t, {}); + mockFacade(t, { + loadEngineSession: async () => ({ + payload: { ok: false, error: "offline", code: "no_client" }, + statusHint: 503, + gate: {}, + transport: "acp", + }), + }); + const route = await loadRoute(); + const bus = await import(absPath("lib/state-bus.js")); + const cs = mkCs(); + bus.clients.set("tab-load", cs); + try { + const res = mkRes(); + await route.handleLoadSession(jsonReq({ sessionId: "mvs_x" }), res, { cs, cid: "tab-load" }); + const seen = lastResponse(res); + assert.equal(seen.status, 503); + assert.equal(seen.body, '{"ok":false,"error":"offline","code":"no_client"}'); + assert.equal(cs.running.active, true, "a failed load must not re-assert an at-rest frame"); + } finally { + bus.clients.delete("tab-load"); + } + }); + + test("PROOF: #70's capability error ESCAPES the route, for the router's central 501", async (t) => { + // The route must not catch it. A catch would turn "the engine + // cannot do this" into a 500, which is the fake success the gate + // exists to prevent — and it would be the ONLY endpoint in M3 to + // swallow the structured error. + await setupMocks(t, {}); + const marker = new Error("B7-LOAD-MOCK-WAS-NOT-HONOURED"); + marker.capability = "sessionCrud"; + mockFacade(t, { + loadEngineSession: async () => { + throw marker; + }, + }); + const route = await loadRoute(); + let caught = null; + try { + await route.handleLoadSession(jsonReq({ sessionId: "mvs_x" }), mkRes(), { + cs: mkCs(), + cid: "tab-1", + }); + } catch (err) { + caught = err; + } + assert.ok(caught, "the route swallowed the capability error — it must not have a catch here"); + assert.equal(caught, marker, "the error is the mock's, by identity"); + }); + + test("#71: the route writes the facade's body and pushes state on success", async (t) => { + await setupMocks(t, {}); + let seenArgs = null; + mockFacade(t, { + activateEngineSession: async (args) => { + seenArgs = args; + return { + payload: { ok: true, activeSessionId: "mvs_x", data: {} }, + statusHint: 200, + gate: {}, + transport: "acp", + }; + }, + }); + const route = await loadRoute(); + const cs = mkCs(); + const res = mkRes(); + await route.handleActivateSession(jsonReq({ sessionId: "mvs_x" }), res, { cs, cid: "tab-1" }); + assert.deepEqual(seenArgs, { sessionId: "mvs_x", cs }); + const seen = lastResponse(res); + assert.equal(seen.status, 200); + assert.equal(seen.body, '{"ok":true,"activeSessionId":"mvs_x","data":{}}'); + }); + + test("#71: the pre-existing 501 for `unsupported` still reaches the client", async (t) => { + await setupMocks(t, {}); + mockFacade(t, { + activateEngineSession: async () => ({ + payload: { ok: false, error: "no", code: "unsupported" }, + statusHint: 501, + gate: {}, + transport: "acp", + }), + }); + const route = await loadRoute(); + const res = mkRes(); + await route.handleActivateSession(jsonReq({ sessionId: "mvs_x" }), res, { + cs: mkCs(), + cid: "tab-1", + }); + const seen = lastResponse(res); + assert.equal(seen.status, 501); + assert.equal(seen.body, '{"ok":false,"error":"no","code":"unsupported"}'); + }); + + test("PROOF: a marker error from the activate facade escapes the route", async (t) => { + await setupMocks(t, {}); + const marker = new Error("B7-ACTIVATE-MOCK-WAS-NOT-HONOURED"); + mockFacade(t, { + activateEngineSession: async () => { + throw marker; + }, + }); + const route = await loadRoute(); + let caught = null; + try { + await route.handleActivateSession(jsonReq({ sessionId: "mvs_x" }), mkRes(), { + cs: mkCs(), + cid: "tab-1", + }); + } catch (err) { + caught = err; + } + assert.ok(caught, "either the mock did not take, or the route grew a catch"); + assert.equal(caught, marker, "the error is the mock's, by identity"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 54af5e375..f83cd2969 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -28,6 +28,7 @@ ".github/workflows/sync-issue-to-feishu.yml", ".gitignore", ".gitleaks.toml", + ".gitleaksignore", ".npmrc", "AGENTS.md", "CONTRIBUTING.md", @@ -3451,11 +3452,13 @@ "packages/webui/server/engine/errors.js", "packages/webui/server/engine/host.js", "packages/webui/server/engine/index.js", + "packages/webui/server/engine/interrupt.js", "packages/webui/server/engine/model-reads.js", "packages/webui/server/engine/providers/local-runtime-v2.capabilities.js", "packages/webui/server/engine/providers/local-runtime-v2.js", "packages/webui/server/engine/providers/tui-runtime-adapter.js", "packages/webui/server/engine/session-export.js", + "packages/webui/server/engine/session-load.js", "packages/webui/server/engine/session-reads.js", "packages/webui/server/engine/session-switch.js", "packages/webui/server/engine/session-tree-reads.js", @@ -3601,8 +3604,10 @@ "packages/webui/test/lib/engine/capability-reads.test.js", "packages/webui/test/lib/engine/capability-snapshot.test.js", "packages/webui/test/lib/engine/host-facade.test.js", + "packages/webui/test/lib/engine/interrupt.test.js", "packages/webui/test/lib/engine/model-reads.test.js", "packages/webui/test/lib/engine/session-export.test.js", + "packages/webui/test/lib/engine/session-load.test.js", "packages/webui/test/lib/engine/session-reads.test.js", "packages/webui/test/lib/engine/session-switch.test.js", "packages/webui/test/lib/engine/session-tree-reads.test.js",