diff --git a/.gitleaks.toml b/.gitleaks.toml index e1a9fe8d..1ce56bec 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 00000000..875e238d --- /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/server/engine/index.js b/packages/webui/server/engine/index.js index 589a6ac9..d844df2e 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 00000000..2dabc6bf --- /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 00000000..ea057e20 --- /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 be02e28b..23e9d857 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 10d49047..1931a39c 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 00000000..2eae20f4 --- /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 00000000..73e3d022 --- /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 54af5e37..f83cd296 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",