From f2f993dc3fd3430a860caec2324bedbdbc226350 Mon Sep 17 00:00:00 2001 From: s39-dev Date: Wed, 30 Sep 2026 21:59:47 +0800 Subject: [PATCH 1/3] test(docs): check that every symbol citation resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture pair cited fifty symbols to files and twelve of them were wrong — a 24% error rate on a document that presents itself as the authoritative account of how the build is wired. getCachedMcodeCommands was filed under state-bus.js when it lives in acp-client.js; three symbols named functions the ACP refactor deleted; two files no longer exist at all. Checking by hand does not scale, so the citation check runs now: every bare path must exist, every file#symbol and "(in file)" must resolve to a definition once import lines are stripped, and the two language copies must mirror each other. It is wired into the verifier because an unwired check is decoration — this script previously ran only by hand and no change would have exercised it. It is not a complete fence: a symbol named in prose with no file attached is invisible to it, and it proves existence, not accuracy. Two whitelists need a human to keep honest. --- .../webui/scripts/check-docs-alignment.mjs | 273 +++++++++++++++++- scripts/verify.mjs | 13 + 2 files changed, 278 insertions(+), 8 deletions(-) diff --git a/packages/webui/scripts/check-docs-alignment.mjs b/packages/webui/scripts/check-docs-alignment.mjs index e36b4d3a8..ce22bf069 100644 --- a/packages/webui/scripts/check-docs-alignment.mjs +++ b/packages/webui/scripts/check-docs-alignment.mjs @@ -5,7 +5,8 @@ // the manifest (`package.json`), the documentation set // (`README.md` + `docs/API.md` + `docs/CAPABILITIES.md` + // `docs/CAPABILITIES.zh-CN.md`), the security disclosure -// (`references/SECURITY-NOTES.md`), and the +// (`references/SECURITY-NOTES.md`), the architecture document pair +// (`docs/ARCHITECTURE.md` + `docs/ARCHITECTURE.zh-CN.md`), and the // server code (`server/router.js`, `server/lib/config.js`). // // Each check prints a one-line PASS or a list of mismatches with the @@ -19,7 +20,7 @@ // // No external deps — Node 22+ stdlib only. -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; @@ -145,7 +146,7 @@ const configSrc = read("server/lib/config.js"); // English document (ticket 51 F3). // ----------------------------------------------------------------------- -console.log(`${TAG.dim("[1/6]")} package.json → README.md + docs/CAPABILITIES.md; zh-CN mirror alignment`); +console.log(`${TAG.dim("[1/7]")} package.json → README.md + docs/CAPABILITIES.md; zh-CN mirror alignment`); // Ordered `## N. ` heading numbers of a CAPABILITIES document. function sectionNumbers(doc) { @@ -221,7 +222,7 @@ check( // for the canonical list; we only assert on what README itself mentions. // ----------------------------------------------------------------------- -console.log(`${TAG.dim("[2/6]")} README.md endpoint mentions → server/router.js`); +console.log(`${TAG.dim("[2/7]")} README.md endpoint mentions → server/router.js`); const readmeEndpoints = [ ...readme.matchAll(/`(GET|POST|DELETE|PUT|PATCH)\s+(\/api\/[A-Za-z0-9_\-\/:.]+)`/g), ].map((m) => ({ method: m[1], path: m[2].split("?")[0] })); @@ -251,7 +252,7 @@ for (const { method, path } of readmeEndpoints) { // scan those and assert each (method, path) is wired up in router.js. // ----------------------------------------------------------------------- -console.log(`${TAG.dim("[3/6]")} docs/API.md endpoints → server/router.js`); +console.log(`${TAG.dim("[3/7]")} docs/API.md endpoints → server/router.js`); const apiEndpoints = [ ...apiDoc.matchAll(/### `((?:GET|POST|DELETE|PUT|PATCH)(?:\s*\|\s*(?:GET|POST|DELETE|PUT|PATCH))*) (\/api\/[^`?]+)/g), ].map((m) => { @@ -341,7 +342,7 @@ const KNOWN_ENV_VARS = new Set([ "DEBUG_INJECT", ]); -console.log(`${TAG.dim("[4/6]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`); +console.log(`${TAG.dim("[4/7]")} references/SECURITY-NOTES.md env vars → server/lib/config.js`); // Env-var tokens in SECURITY-NOTES are mostly `TOKEN`, `HOST`, `PORT`, // `MCODE_RUNTIME_DB`, `MCODE_WEBUI_UPLOAD_DIR`, `MCODE_WEBUI_SETTINGS_PATH`, // `MAVIS_DATA_DIR`, `MCODE_MODEL`, `MCODE_CMD`, `MCODE_WORKSPACE`, @@ -383,7 +384,7 @@ if (envVars.size === 0) { // manifest is JSON-clean. Already done implicitly by parseJson() above.) // ----------------------------------------------------------------------- -console.log(`${TAG.dim("[5/6]")} package.json round-trip parse + capability shape`); +console.log(`${TAG.dim("[5/7]")} package.json round-trip parse + capability shape`); const capsObjects = pkgJson.mcodeWebui?.capabilities ?? []; check( "package.json round-trip JSON parse", @@ -432,7 +433,7 @@ check( // (Hono `OWNED_ROUTES` set). Either side satisfies the anti-pattern. // ----------------------------------------------------------------------- -console.log(`${TAG.dim("[6/6]")} known drift: cleanup-orphans endpoint consistency`); +console.log(`${TAG.dim("[6/7]")} known drift: cleanup-orphans endpoint consistency`); const apiHasCleanup = apiDoc.includes("cleanup-orphans"); // Legacy: `{ method: "POST", match: ... cleanup-orphans ... }` style. // Hono: a literal `"POST /api/sessions/cleanup-orphans"` in OWNED_ROUTES. @@ -467,6 +468,262 @@ if (apiHasCleanup && !registeredAnywhere) { ); } +// ----------------------------------------------------------------------- +// Check 7: every `file#symbol` and bare-path citation in +// docs/ARCHITECTURE.md + docs/ARCHITECTURE.zh-CN.md resolves. +// +// Ticket 95 measured a 24% distortion rate on this document's +// symbol→file citations (12 of 50 wrong, spread across all four +// failure classes: wrong file, removed symbol, removed file, ambiguous +// phrasing). Both reported cites were *plausible* — a reader greps, +// lands on a real file, and reads the wrong code. A citation gate is +// the only thing that catches that before review does. +// +// Three mechanical sub-checks, because each catches a different class: +// 7a a bare `path.ext` cited in either document exists on disk +// (catches "the file was deleted" — e.g. the old `render.js`) +// 7b a `file.ext#symbol` cite resolves AND that file *defines* the +// symbol, with import lines stripped so "X imports it" does not +// count as "X defines it" (catches the reported bug: +// `getCachedMcodeCommands()` cited as "(in `state-bus.js`)" +// when acp-client.js is the defining module) +// 7c both language mirrors cite the same file#symbol pairs, so a +// correction cannot land on one side only +// +// What this does NOT cover, stated plainly so nobody over-trusts it: +// a symbol named in prose with no file binding ("Both expose +// `stopExec()`") is invisible to a path-driven gate. Those still need +// a human, or a bespoke assertion for that specific symbol. +// ----------------------------------------------------------------------- + +console.log(`${TAG.dim("[7/7]")} docs/ARCHITECTURE*.md symbol→file citations`); + +const REPO_ROOT = resolve(ROOT, "..", ".."); +const archDoc = read("docs/ARCHITECTURE.md"); +const archZhDoc = read("docs/ARCHITECTURE.zh-CN.md"); + +// Paths a source checkout legitimately has no copy of. Keep this short +// and justified — every entry is a path whose absence is correct. +const NOT_ON_DISK = new Set([ + "dist/webui/server.js", // build output; produced by scripts/build.mjs + "server/routes/foo.js", // the illustrative path in §9's recipe + "sessions.json", // runtime data under WEBUI_DATA_DIR, not a source file + "mcp.json", // user-authored MCP server config, not a source file + "index.html", // Next export output (webapp/out/index.html), not a source file +]); + +// Filename-shaped tokens that are not citations of a file in this repo. +const NOT_A_CITATION = new Set([ + "Next.js", // "Next.js 14.2.35" — a framework version +]); + +// A doc citation is relative to one of these roots, tried in order. The +// architecture doc is written from several vantages at once — "config.js" +// is a lib module, "app/page.tsx" sits under webapp/ — so a single root +// would produce false failures. +const PATH_ROOTS = [ + ROOT, + resolve(ROOT, "webapp"), + resolve(ROOT, "webapp", "app"), + resolve(ROOT, "webapp", "components"), + resolve(ROOT, "webapp", "lib"), + resolve(ROOT, "webapp", "public"), + resolve(ROOT, "webapp", "styles"), + resolve(ROOT, "server"), + resolve(ROOT, "server", "lib"), + resolve(ROOT, "server", "routes"), + resolve(ROOT, "server", "trajectory"), + resolve(ROOT, "docs"), + resolve(ROOT, "public"), + resolve(ROOT, "public", "trajectory", "js"), + REPO_ROOT, + resolve(REPO_ROOT, "scripts"), +]; + +// File extensions a citation may carry. Extensionless tokens +// (`agent-modules/skills`) and directory-ish tokens (`out/`) are +// deliberately out of scope. +const CITE_EXT = "(?:js|mjs|cjs|ts|tsx|css|html|json|md|yml|yaml)"; + +function resolveDocPath(docPath) { + for (const base of PATH_ROOTS) { + const abs = resolve(base, docPath); + if (existsSync(abs) && statSync(abs).isFile()) return abs; + } + return null; +} + +// `mcode-{acp,exec}.js#finalize` → ["mcode-acp.js#finalize", +// "mcode-exec.js#finalize"]. Brace alternation is the only expansion the +// documents use. +function expandBraces(token) { + const m = token.match(/^([^{}]*)\{([^{}]*)\}([^{}]*)$/); + if (!m) return [token]; + return m[2] + .split(",") + .flatMap((alt) => expandBraces(`${m[1]}${alt.trim()}${m[3]}`)); +} + +// Capture group 1 of every `re` match in `doc`, brace alternation expanded. +// `matchAll`, not `match` — a global `String.match` yields full-match +// STRINGS, so `m[1]` would index a character rather than a group. +function expandAll(doc, re) { + return [...doc.matchAll(re)].flatMap((m) => expandBraces(m[1])); +} + +// Does `fileAbs` *define* `symbol`? Import lines are stripped first: a +// module that imports a symbol obviously mentions it, and accepting +// that would let the very bug this check exists for pass silently. +function definesSymbol(fileAbs, symbol) { + let src; + try { + src = readFileSync(fileAbs, "utf8"); + } catch { + return false; + } + const body = src + .split("\n") + .filter( + (line) => + !/^\s*import\b/.test(line) && !/^\s*export\b.*\bfrom\b/.test(line), + ) + .join("\n"); + const esc = escapeRegex(symbol); + return new RegExp( + [ + `(?:^|[\\s;{(=])(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\*?\\s+${esc}\\b`, + `(?:export\\s+)?(?:const|let|var|class|type|interface|enum)\\s+${esc}\\b`, + `export\\s*(?:type\\s*)?\\{[^}]*\\b${esc}\\b[^}]*\\}`, + ].join("|"), + ).test(body); +} + +// A path citation, matched ANYWHERE in the document — not only inside +// backticks. The reported drift included a path written bare inside a +// mermaid participant label (`participant B as Browser render.js`), which +// a backtick-scoped pattern walks straight past. +const BARE_RE = new RegExp( + "(?:^|[^\\w./@-])([\\w][\\w./@-]*\\.(?:" + CITE_EXT + "))(?![\\w])", + "g", +); +// `file.ext#symbol` — the compact cite form. +const HASH_RE = new RegExp( + "`([^`\\s]+\\.(?:" + CITE_EXT + "))#([A-Za-z_$][\\w$]*)`", + "g", +); +// `symbol()` (in `file.ext`) and the Chinese equivalents +// `symbol()`(位于 `file.ext`) / (在 `file.ext` 中) — the parenthetical +// cite form. This is the shape the reported `getCachedMcodeCommands()` +// defect actually used, so a gate that ignores it guards nothing. +// +// The paren class accepts half- and full-width forms; the zh-CN mirror +// writes (), and a gate that only understood the ASCII pair would pass +// the Chinese document by never matching anything in it. +const PAREN_RE = new RegExp( + "`([A-Za-z_$][\\w$]*)\\(\\)?`[^\\n]{0,24}?[(\\uFF08](?:in|位于|在)\\s+`" + + "([^`\\s]+\\.(?:" + CITE_EXT + "))`", + "g", +); + +const barePaths = new Map(); // token → [doc names] +const hashCites = new Map(); // "file#symbol" → [doc names] + +function note(map, key, docName) { + if (!map.has(key)) map.set(key, []); + const list = map.get(key); + if (!list.includes(docName)) list.push(docName); +} + +for (const [name, doc] of [ + ["ARCHITECTURE.md", archDoc], + ["ARCHITECTURE.zh-CN.md", archZhDoc], +]) { + for (const token of expandAll(doc, BARE_RE)) { + if (token.includes("*") || NOT_ON_DISK.has(token)) continue; + // A product name that merely looks like a filename. `Next.js 14.2.35` + // is a version, not a citation — keep this list explicit and justified. + if (NOT_A_CITATION.has(token)) continue; + // A route table entry or a URL fragment is not a file citation. + if (token.startsWith("/") || token.startsWith("http")) continue; + note(barePaths, token, name); + } + // The parenthetical form names its symbol in a separate backtick span, + // so record it as a `file#symbol` pair and reuse the same verdict path. + for (const m of doc.matchAll(PAREN_RE)) { + for (const file of expandBraces(m[2])) { + note(hashCites, `${file}#${m[1]}`, name); + } + } + // Brace alternation can sit in the file half (`mcode-{acp,exec}.js#x`), + // so expand per match rather than over a pre-flattened token list. + for (const m of doc.matchAll(HASH_RE)) { + for (const file of expandBraces(m[1])) { + note(hashCites, `${file}#${m[2]}`, name); + } + } +} + +if (barePaths.size === 0 && hashCites.size === 0) { + check( + "docs/ARCHITECTURE.md contains file citations to verify", + false, + ["no path or file#symbol citation was extracted — the extractor regex probably broke"], + ); +} + +for (const [token, docs] of [...barePaths].sort()) { + check( + `${docs.join(" + ")}: cited path \`${token}\` exists`, + resolveDocPath(token) !== null, + [ + `\`${token}\` is cited in ${docs.join(" and ")} but no file with that name exists under packages/webui/ or the repo root.`, + "If it was removed, delete the citation or state what replaced it; if it is build output, add it to NOT_ON_DISK with a reason.", + ], + ); +} + +for (const [token, docs] of [...hashCites].sort()) { + const hashAt = token.indexOf("#"); + const file = token.slice(0, hashAt); + const symbol = token.slice(hashAt + 1); + const abs = resolveDocPath(file); + if (abs === null) { + check(`${docs.join(" + ")}: cited path \`${file}\` exists`, false, [ + `\`${file}#${symbol}\` is cited in ${docs.join(" and ")} but \`${file}\` does not exist.`, + ]); + continue; + } + check( + `${docs.join(" + ")}: \`${file}\` defines \`${symbol}\``, + definesSymbol(abs, symbol), + [ + `\`${symbol}\` is cited in ${docs.join(" and ")} as living in \`${file}\`, but that file does not define it.`, + `It may have moved (locate it with: grep -rn "${symbol}" packages/webui) or the file may only import it — say which.`, + ], + ); +} + +const citeSet = (doc) => + new Set( + [...doc.matchAll(HASH_RE)].flatMap((m) => + expandBraces(m[1]).map((f) => `${f}#${m[2]}`), + ), + ); + +const enCites = citeSet(archDoc); +const zhCites = citeSet(archZhDoc); +const onlyEn = [...enCites].filter((c) => !zhCites.has(c)); +const onlyZh = [...zhCites].filter((c) => !enCites.has(c)); +check( + "docs/ARCHITECTURE.md and .zh-CN.md cite the same file#symbol pairs", + onlyEn.length === 0 && onlyZh.length === 0, + [ + ...onlyEn.map((c) => `cited only in ARCHITECTURE.md: ${c}`), + ...onlyZh.map((c) => `cited only in ARCHITECTURE.zh-CN.md: ${c}`), + "Both documents are hand-maintained at equal weight; a correction must land on both sides.", + ], +); + // ----------------------------------------------------------------------- // Summary + exit code // ----------------------------------------------------------------------- diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 6da126a34..1753902ad 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -40,6 +40,19 @@ const preview = path.join(temporary ?? tmpdir(), "minimax-code-source.tar.gz"); const steps = [ { name: "check:source", script: "check:source", docs: true, windows: true }, { name: "check:tsconfig", script: "check:tsconfig", docs: true, windows: true }, + // Documentation-vs-code alignment: capability names, registered endpoints, + // env vars exported by config.js, and the symbol→file citations in + // docs/ARCHITECTURE.md + its zh-CN mirror. Ticket 95 measured a 24% + // distortion rate on those citations, so they are now checked rather than + // trusted. It was previously reachable only via `pnpm --filter @mavis/webui + // check`, i.e. by nothing that runs on a change — a gate nobody invokes + // prevents no drift. + { + name: "check:docs-alignment", + docs: true, + windows: true, + command: ["packages/webui/scripts/check-docs-alignment.mjs"], + }, { name: "export source preview", docs: true, From 7c0012a58c0edb75b1c90b6f619296521ec34e75 Mon Sep 17 00:00:00 2001 From: s39-dev Date: Wed, 30 Sep 2026 21:59:49 +0800 Subject: [PATCH 2/3] docs(webui): correct twelve symbol citations in the architecture pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted ones are now recorded as removed, with the path that replaced them, rather than given an invented rename. render.js went with the vanilla-JS SPA; cancelSession is what cancellation uses now. Two citations were subtler than a stale file name. The sidebar's renderSessions is a same-named symbol in the trajectory studio — the main UI is session-tree.tsx#SessionTree. And section 9 tells new endpoints to join the router.js table, but most of them now live in the Hono OWNED_ROUTES in server/app.js, so following the text would put new work in the wrong file. --- packages/webui/docs/ARCHITECTURE.md | 113 ++++++++++++++-------- packages/webui/docs/ARCHITECTURE.zh-CN.md | 109 ++++++++++++--------- 2 files changed, 134 insertions(+), 88 deletions(-) diff --git a/packages/webui/docs/ARCHITECTURE.md b/packages/webui/docs/ARCHITECTURE.md index 9c957f726..37917c997 100644 --- a/packages/webui/docs/ARCHITECTURE.md +++ b/packages/webui/docs/ARCHITECTURE.md @@ -38,6 +38,8 @@ ┌──────────────────────────────────────────────────────────────────────┐ │ server/router.js — declarative route table │ │ │ + │ Gate chain (Gates 1→5) live in server/lib/gates.js#runGates, which │ + │ router.js delegates to; both layers share it: │ │ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │ │ │ │ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │ @@ -75,7 +77,7 @@ │ mcode-session-delete · sessions · state-bus · acp-client │ │ mcode-rpc · mcode-acp · mcode-exec · chat-line · context-percent │ │ mavis-usage · usage · settings · upload · workspace · slash · │ - │ static │ + │ static · gates · auth · alerts · trajectory │ └──────────────────────────────────────────────────────────────────────┘ │ ▲ ▼ │ JSON-RPC over stdio @@ -179,7 +181,7 @@ sequenceDiagram participant A as acp.mjs (prompt callbacks) participant M as lib/mcode-acp.js
streamAcpPrompt participant S as state-bus.js
pushStateFor - participant B as Browser render.js
parseChatLines → renderMessage + participant B as webapp/lib/transcript.ts
decodeTranscript → components/chat.tsx loop per model chunk E-->>A: session/update agent_thought_chunk @@ -223,7 +225,7 @@ Interactive surfaces and engine-side modules — who owns what: | **ask_user tool** | engine emits `ask_user` tool call | chat line `→ ask_user {json}` | modal with options/multi-select/Other; answer → `POST /api/send {isAskAnswer:true}` | | **Permission prompts** | engine requests approval for a tool call | permission events → modal (ask/auto/full) | answer forwarded on the send path | | **Plan mode** | `Plan:`-prefixed prompt → structured plan event | plan-review modal | agree / skip / add context → forwarded | -| **Trajectory studio** | reads runtime SQLite projection (read-only) | `/api/trajectory/*` | `/trajectory/` panel (turns, tokens, compaction, subagents) | +| **Trajectory studio** | reads runtime SQLite projection (read-only) | `/trajectory/api/*` (its own backend, `server/trajectory/http.mjs`) | `/trajectory/` panel (turns, tokens, compaction, subagents) | Round-trip for interactive prompts (ask_user / permission / plan): @@ -285,7 +287,7 @@ flowchart TD K[("runtime-state.sqlite
mcode engine sessions")] end - subgraph SIDEBAR["sidebar (renderSessions)"] + subgraph SIDEBAR["sidebar (webapp/components/session-tree.tsx)"] L["merge: mcode sessions (workspace-filtered)
+ webui records, dedupe by mcodeSessionId
kinds: mcode / webui-mcode / webui"] end @@ -359,25 +361,37 @@ The chokepoint. Exports: | Function | Purpose | |---|---| -| `getClient(cid)` | Returns the `clientState` object: `state`, `sse`, `activeChild`, `chatHistory`, `requestSeq`. Lazily creates on first call. | -| `pushStateFor(cid, opts)` | Build a normalized `state` object and write it to `clientState.state`. Broadcasts to the SSE channel unless `opts.silent`. | +| `getClient(cid)` | Returns the per-cid `clientState` object, created by `makeClientState()` and restored via `restoreLatestSession()` on first call. The object *is* the state — there is no `clientState.state` wrapper. The per-cid side tables live beside it, not inside it: `sseByCid` (SSE response per cid) and `activeChildByCid` (child process per cid). | +| `pushStateFor(cid, opts)` | Build a normalized `state` object from `clientState` and broadcast it to the SSE channel unless `opts.silent`. | | `pushOnlineCount(lanBroadcast)` | Count `sseByCid.size` and broadcast to all clients. Called on connect/disconnect. | | `SSE_HEADERS` | Standard headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`. | -The `state` payload is documented in § 5 below. The `clientState.state` +The `state` payload is documented in § 4 below. A `clientState` object is the **only** thing the rest of the codebase reads from. -### `acp-client.js` -Wraps mcode's JSON-RPC-over-stdio protocol. Exports: - -- `McodeAcpClient` class — `start()`, `request(method, params)`, - `notify(method, params)`, `stop()`, `events` EventEmitter. -- `getMcodeAcpClient()` — process-wide singleton. Init is - `pInitPromise` de-duplicated so concurrent `start()` callers share a - single subprocess. -- Cache: `mcodeSessionsCache` (in `acp-client.js`) and - `getCachedMcodeCommands()` (in `state-bus.js`) avoid - repeated JSON-RPC round-trips for `session/list` and +### `acp.mjs` and `acp-client.js` +Two distinct files, and the split matters when you grep for a symbol: + +- `acp.mjs` (at the package root, `packages/webui/acp.mjs`) is the + zero-dependency JSON-RPC-over-stdio transport. It **defines** + `class McodeAcpClient` — `start()`, `request(method, params)`, + `notify(method, params)`, `stop()`, `events` EventEmitter — and + answers every engine→client request. +- `server/lib/acp-client.js` is the webui-side cache and lifecycle + wrapper *around* that transport. It imports `McodeAcpClient` from + `acp.mjs`; it does not define or re-export it. Its own exports: + `getMcodeAcpClient()` — the process-wide singleton, whose init is + de-duplicated by the module-level `_mcodeAcpInitPromise` so + concurrent callers share one subprocess — plus + `getCatalogueHost()`, `listAllMcodeSessions()`, + `getMcodeSessionsForWorkspace()`, `getMcodeSessionTitle()`, + `invalidateMcodeSessionsCache()`, `shutdownMcodeAcpSingleton()`, + `getMcodeServerInfo()`, `WEBUI_LOCAL_COMMANDS`, and + `ensureMcodeCommands()`. +- Cache: `mcodeSessionsCache` and `getCachedMcodeCommands()` are both + module state of `acp-client.js`. `state-bus.js` only *imports* + `getCachedMcodeCommands()` when it builds a snapshot. Both caches + avoid repeated JSON-RPC round-trips for `session/list` and `session/commands`. ### `mcode-rpc.js` @@ -435,10 +449,17 @@ is anything other than `Full access` (the first branch of opt-in; `mcode-rpc.js` does not select transports — it only talks to whatever child is currently registered. -Both expose: -- `runMcode(content, opts)` → `AsyncGenerator` -- `stopExec()` → `void` -- `isRunning()` → `boolean` +Each exposes one entry point, named after its transport: +- `mcode-acp.js` → `runMcodeAcp(content, opts)` → `AsyncGenerator` +- `mcode-exec.js` → `runMcodeExec(content, opts)` → `AsyncGenerator` + +> **Removed symbols.** Earlier revisions of this section documented a shared +> triple — `runMcode(content, opts)`, `stopExec()` and `isRunning()` — as +> exported by both transports. None of the three exists any more. The single +> entry point was split per transport, and the stop and status questions are +> answered elsewhere: cancellation goes through `mcode-rpc.js#cancelSession`, +> and run status is read off the `running` field of the per-cid `clientState`. +> There is no rename you can follow here — these are gone, not moved. `NormalizedEvent` is a tagged union (`{type, …}`) with these types: `state`, `chat`, `delta`, `tool`, `permission`, `plan`, `ask`, @@ -465,7 +486,7 @@ done \| stopped`) is a projection-layer product, not a stored value; webui does not import it but adopts the same shape. Unknown future statuses render as `idle`, never a false `running`. -## 4. The `clientState.state` payload +## 4. The `clientState` payload This is the shape every SSE `state` event contains. The webui mirrors it 1:1 into the `state` JS variable. @@ -487,12 +508,11 @@ it 1:1 into the `state` JS variable. ctx: string, // e.g. "512k" thinking: 'On'|'Off'|string }, permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|... - commands: Array<{ // mcode slash commands - cmd: string, zh: string, en: string, - description_zh?: string, description_en?: string, - hint?: string, - input_hint?: string, - destructive?: boolean }>, + availableCommands: Record< // mcode slash commands, grouped + string, // e.g. { mcode: [{name, description}, …] } + Array<{ name: string, + description?: string }> // the composer flattens this to a name[] palette + >, sessions: Array<{ // webui-side session list (merged w/ mcode) id: string, title: string, @@ -707,10 +727,11 @@ another thing the user had to install or whose absence could silently break the plugin; the safe answer was "no dependencies at all". That reasoning no longer holds: the webui is now an in-tree workspace member with a build step, its server is produced by `scripts/build.mjs` as `dist/webui/server.js`, and -the published archive (`scripts/lib/cli-release.mjs` + `releaseManifest`) pins -every external module. The cost of a hand-copied implementation is now higher -than the cost of importing a real package, because the copy cannot be checked -by the build pipeline. +the published archive pins every external module: `cliExternalModules` in +`scripts/lib/cli-release.mjs` lists the allow-list, and `releaseManifest()` +in `scripts/package-cli-release.mjs` builds the manifest itself. The cost of +a hand-copied implementation is now higher than the cost of importing a real +package, because the copy cannot be checked by the build pipeline. The "no bundling" comment in `scripts/build.mjs` is owned by workstream 1 and will be removed when its bundle entry point lands. This document is the @@ -724,7 +745,7 @@ stale. | mcode acp subprocess crashes | `child.on('exit')` listener | pushStateFor with `running.active=false`; client shows "agent stopped" toast | | mcode acp returns "Method not found" | `mcode-rpc.js` whitelist | returns `{ok:false, code:'unsupported'}` synchronously; route handler returns 501 Not Implemented; client shows toast | | SSE connection drops | `EventSource.onerror` | auto-reconnect with backoff; on reconnect, fetch `/api/state` and resync | -| LAN request from a non-whitelisted IP | `router.js` L120 | 403 + friendly HTML page (or JSON for /api/*) | +| LAN request from a non-whitelisted IP | `server/lib/gates.js#runGates` (called from `router.js`) | 403 + friendly HTML page (or JSON for /api/*) | | Server out of file descriptors | `installGlobalErrorHandlers` EMFILE sink | written to `.server.err`; user sees an empty page; reload usually fixes it | | mcode exec encoding is GBK (Windows) | Node defaults to UTF-8 in `spawn`; no fix needed | documented in README as a pitfall for future Python ports | @@ -733,15 +754,21 @@ stale. The pattern (see `docs/DEVELOPMENT.md` for the full walk-through): 1. Create `server/routes/foo.js`, export `async function handleFoo(req, res, ctx, pathname)` -2. Import in `server/router.js` -3. Add to the routes table: - ```js - { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo } - ``` -4. If the new endpoint mutates state, call `pushStateFor(cid, {...})` from - the handler. Never write to `clientState.state` directly. -5. If the endpoint is invoked by the webui, add it to the fetch helper in - `packages/webui/webapp/lib/api.ts` (`API_SUFFIX` is automatically appended). +2. Register it — with the layer that owns it today: + - Most endpoints are **Hono-owned**. Add the `METHOD /api/foo` literal to + `OWNED_ROUTES` in `server/app.js` and wire `app.post("/api/foo", …)` + there. `OWNED_ROUTES` is the ledger of what Hono serves. + - The legacy `ROUTES` table in `server/router.js` still owns a small set + (`/api/health`, `GET /api/events`, `GET /api/alerts`, `POST + /api/settings`) plus the static and `/trajectory/` handling. Add a + `{ method, match, handler }` entry only if the endpoint belongs there. +3. If the new endpoint mutates state, call `pushStateFor(cid, {...})` from + the handler. Never write to the `clientState` object directly. +4. If the endpoint is invoked by the webui, add a typed method to + `packages/webui/webapp/lib/api.ts`. It builds the request through the + local `request()` helper, which appends the `cid` query parameter + itself; there is no `API_SUFFIX` constant — earlier revisions of this + document named one, and it has been removed. ## 10. Future directions diff --git a/packages/webui/docs/ARCHITECTURE.zh-CN.md b/packages/webui/docs/ARCHITECTURE.zh-CN.md index 2f9ec3c9a..9b8125f38 100644 --- a/packages/webui/docs/ARCHITECTURE.zh-CN.md +++ b/packages/webui/docs/ARCHITECTURE.zh-CN.md @@ -33,6 +33,8 @@ ┌──────────────────────────────────────────────────────────────────────┐ │ server/router.js — declarative route table │ │ │ + │ 门禁链(Gates 1→5)位于 server/lib/gates.js#runGates, │ + │ router.js 委派给它;两个 HTTP 层共用同一条链: │ │ LAN guard: !isLocalRequest(req) && !getLanBroadcast() → 403 │ │ │ │ ┌─ static ┐ ┌─ /api/health ┐ ┌─ /api/state ┐ ┌─ /api/sessions ┐ │ @@ -70,7 +72,7 @@ │ mcode-session-delete · sessions · state-bus · acp-client │ │ mcode-rpc · mcode-acp · mcode-exec · chat-line · context-percent │ │ mavis-usage · usage · settings · upload · workspace · slash · │ - │ static │ + │ static · gates · auth · alerts · trajectory │ └──────────────────────────────────────────────────────────────────────┘ │ ▲ ▼ │ JSON-RPC over stdio @@ -174,7 +176,7 @@ sequenceDiagram participant A as acp.mjs(prompt 回调) participant M as lib/mcode-acp.js
streamAcpPrompt participant S as state-bus.js
pushStateFor - participant B as 浏览器 render.js
parseChatLines → renderMessage + participant B as webapp/lib/transcript.ts
decodeTranscript → components/chat.tsx loop 每个模型分块 E-->>A: session/update agent_thought_chunk @@ -218,7 +220,7 @@ sequenceDiagram | **ask_user 工具** | 引擎发出 `ask_user` 工具调用 | 聊天行 `→ ask_user {json}` | 带选项/多选/其他的弹窗;回答 → `POST /api/send {isAskAnswer:true}` | | **权限提示** | 引擎为某个工具调用请求批准 | 权限事件 → 弹窗(ask/auto/full) | 回答经发送路径转发 | | **计划模式(Plan mode)** | 以 `Plan:` 为前缀的提示词 → 结构化计划事件 | 计划评审弹窗 | 同意 / 跳过 / 补充上下文 → 转发 | -| **轨迹工作室** | 读取运行时 SQLite 投影(只读) | `/api/trajectory/*` | `/trajectory/` 面板(回合、令牌、压缩、子代理) | +| **轨迹工作室** | 读取运行时 SQLite 投影(只读) | `/trajectory/api/*`(自带后端,`server/trajectory/http.mjs`) | `/trajectory/` 面板(回合、令牌、压缩、子代理) | 交互式提示(ask_user / 权限 / 计划)的往返流程: @@ -277,7 +279,7 @@ flowchart TD K[("runtime-state.sqlite
mcode 引擎会话")] end - subgraph SIDEBAR["侧栏(renderSessions)"] + subgraph SIDEBAR["侧栏(webapp/components/session-tree.tsx)"] L["合并:mcode 会话(按工作区过滤)
+ webui 记录,按 mcodeSessionId 去重
类别:mcode / webui-mcode / webui"] end @@ -350,26 +352,33 @@ flowchart TD | 函数 | 用途 | |---|---| -| `getClient(cid)` | 返回 `clientState` 对象:`state`、`sse`、`activeChild`、`chatHistory`、`requestSeq`。首次调用时惰性创建。 | -| `pushStateFor(cid, opts)` | 构建规范化的 `state` 对象并写入 `clientState.state`。除非 `opts.silent`,否则向 SSE 通道广播。 | +| `getClient(cid)` | 返回按 cid 的 `clientState` 对象,由 `makeClientState()` 创建、首次调用时经 `restoreLatestSession()` 恢复。该对象**本身就是**状态——不存在 `clientState.state` 这层包装。按 cid 的旁表位于它之外而非其中:`sseByCid`(每个 cid 的 SSE 响应)与 `activeChildByCid`(每个 cid 的子进程)。 | +| `pushStateFor(cid, opts)` | 从 `clientState` 组装规范化的 `state` 对象,除非 `opts.silent`,否则向 SSE 通道广播。 | | `pushOnlineCount(lanBroadcast)` | 统计 `sseByCid.size` 并广播给所有客户端。在连接/断开时调用。 | | `SSE_HEADERS` | 标准头:`Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive`、`X-Accel-Buffering: no`。 | -`state` 载荷在下文 § 5 中说明。`clientState.state` -对象是代码库其余部分**唯一**读取的东西。 - -### `acp-client.js` -封装 mcode 的基于 stdio 的 JSON-RPC 协议。导出: - -- `McodeAcpClient` 类——`start()`、`request(method, params)`、 - `notify(method, params)`、`stop()`、`events` EventEmitter。 -- `getMcodeAcpClient()`——进程级单例。初始化由 - `pInitPromise` 去重,因此并发的 `start()` 调用者共享 - 同一个子进程。 -- 缓存:`mcodeSessionsCache`(位于 `acp-client.js`)和 - `getCachedMcodeCommands()`(位于 `state-bus.js`)避免 - 对 `session/list` 和 `session/commands` 的 - 重复 JSON-RPC 往返。 +`state` 载荷在下文 § 4 中说明。`clientState` 对象是代码库其余部分 +**唯一**读取的东西。 + +### `acp.mjs` 与 `acp-client.js` +两个不同的文件,需要 grep 符号时务必分清: + +- `acp.mjs`(位于包根 `packages/webui/acp.mjs`)是零依赖的、基于 + stdio 的 JSON-RPC 传输层。它**定义** `class McodeAcpClient`—— + `start()`、`request(method, params)`、`notify(method, params)`、 + `stop()`、`events` EventEmitter——并应答引擎发往客户端的每一个请求。 +- `server/lib/acp-client.js` 是包裹该传输层的 webui 侧缓存与生命周期 + 管理器。它从 `acp.mjs` **导入** `McodeAcpClient`,既不定义也不再导出它。 + 它自己的导出是:`getMcodeAcpClient()`——进程级单例,其初始化由模块级 + `_mcodeAcpInitPromise` 去重,因此并发调用者共享同一个子进程——以及 + `getCatalogueHost()`、`listAllMcodeSessions()`、 + `getMcodeSessionsForWorkspace()`、`getMcodeSessionTitle()`、 + `invalidateMcodeSessionsCache()`、`shutdownMcodeAcpSingleton()`、 + `getMcodeServerInfo()`、`WEBUI_LOCAL_COMMANDS`、`ensureMcodeCommands()`。 +- 缓存:`mcodeSessionsCache` 与 `getCachedMcodeCommands()` 同为 + `acp-client.js` 的模块级状态。`state-bus.js` 只是**导入** + `getCachedMcodeCommands()` 来组装快照。两者都避免了 + 对 `session/list` 与 `session/commands` 的重复 JSON-RPC 往返。 ### `mcode-rpc.js` acp 侧的封装。每一个公共函数(`setMode`、`setConfigOption`、 @@ -416,10 +425,15 @@ export const MCODE_ACP_CAPABILITIES = { ### `mcode-acp.js` 与 `mcode-exec.js` 两种传输,共享同一形状。一个回合用哪种传输在引擎启动前就已决定,判定散落两处:`routes/chat.js#handleSend` 在服务端环境变量 `MCODE_USE_ACP=0` 时强制走 `mcode exec`(ACP 协议回归时的逃生阀);`runMcodeAcp` 自身在会话权限模式不是 `Full access` 时改道 `runMcodeExec`(`runMcodeAcp` 的首个分支)。不存在 `/exec` 命令,也没有按请求的显式选择;`mcode-rpc.js` 不做传输选择——它只与当前已注册的子进程通信。 -两者都暴露: -- `runMcode(content, opts)` → `AsyncGenerator` -- `stopExec()` → `void` -- `isRunning()` → `boolean` +两者各自暴露一个入口,名字随传输方式而定: +- `mcode-acp.js` → `runMcodeAcp(content, opts)` → `AsyncGenerator` +- `mcode-exec.js` → `runMcodeExec(content, opts)` → `AsyncGenerator` + +> **已移除的符号。** 本节早期版本记载过一个由两种传输共同导出的三件套—— +> `runMcode(content, opts)`、`stopExec()` 与 `isRunning()`。三者如今都不存在。 +> 单一入口已按传输方式拆分;停止与状态这两个问题改由别处回答:取消走 +> `mcode-rpc.js#cancelSession`,运行状态读取按 cid 的 `clientState` 上的 +> `running` 字段。这里没有可追踪的改名——它们是被删掉了,不是搬走了。 `NormalizedEvent` 是一个带标签的联合类型(`{type, …}`),包含这些类型: `state`、`chat`、`delta`、`tool`、`permission`、`plan`、`ask`、 @@ -444,7 +458,7 @@ db 原始值从不上线。`projectAgentStatus` 合成两列——任务列的 ` queued \| done \| stopped`)是投影层产物、不是存储值;webui 不导入它, 但采用同样的形状。未识别的未来状态渲染为 `idle`,绝不误报"运行中"。 -## 4. `clientState.state` 载荷 +## 4. `clientState` 载荷 这是每个 SSE `state` 事件所包含的形状。webui 将其 1:1 镜像到 `state` JS 变量中。 @@ -466,12 +480,11 @@ queued \| done \| stopped`)是投影层产物、不是存储值;webui 不导 ctx: string, // e.g. "512k" thinking: 'On'|'Off'|string }, permissions: string, // mcode-side: 'ask'|'auto'|'full'|'plan'|... - commands: Array<{ // mcode slash commands - cmd: string, zh: string, en: string, - description_zh?: string, description_en?: string, - hint?: string, - input_hint?: string, - destructive?: boolean }>, + availableCommands: Record< // mcode 斜杠命令,按组划分 + string, // 例如 { mcode: [{name, description}, …] } + Array<{ name: string, + description?: string }> // composer 将其摊平成 name[] 补全面板 + >, sessions: Array<{ // webui-side session list (merged w/ mcode) id: string, title: string, @@ -668,9 +681,10 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务 都意味着用户必须再装一次,或者让插件因缺失依赖而无法启动;最稳妥的 答案就是“无依赖”。这条推理今天已不再成立:webui 现在是 workspace 内成员、有构建步骤,服务器由 `scripts/build.mjs` 产出为 -`dist/webui/server.js`,发布归档(`scripts/lib/cli-release.mjs` 与 -`releaseManifest`)会固定每一条外部模块。手抄实现现在的代价比真接 -一个包更高,因为副本无法被构建流水线验证。 +`dist/webui/server.js`,发布归档会固定每一条外部模块: +`scripts/lib/cli-release.mjs` 的 `cliExternalModules` 给出允许清单, +`scripts/package-cli-release.mjs` 的 `releaseManifest()` 负责生成清单本身。 +手抄实现现在的代价比真接一个包更高,因为副本无法被构建流水线验证。 `scripts/build.mjs` 中的 “no bundling” 注释由 workstream 1 拥有, 在其打包入口落地时会移除。本文档是策略权威;任何源码注释若与 §7 @@ -683,7 +697,7 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务 | mcode acp 子进程崩溃 | `child.on('exit')` 监听器 | 以 `running.active=false` 调用 pushStateFor;客户端显示「agent stopped」toast | | mcode acp 返回 "Method not found" | `mcode-rpc.js` 允许列表 | 同步返回 `{ok:false, code:'unsupported'}`;路由处理器返回 501 Not Implemented;客户端显示 toast | | SSE 连接断开 | `EventSource.onerror` | 带退避的自动重连;重连后拉取 `/api/state` 并重新同步 | -| 来自非白名单 IP 的 LAN 请求 | `router.js` L120 | 403 + 友好的 HTML 页面(/api/* 则返回 JSON) | +| 来自非白名单 IP 的 LAN 请求 | `server/lib/gates.js#runGates`(由 `router.js` 调用) | 403 + 友好的 HTML 页面(/api/* 则返回 JSON) | | 服务器文件描述符耗尽 | `installGlobalErrorHandlers` 的 EMFILE 兜底 | 写入 `.server.err`;用户看到空白页;重新加载通常可修复 | | mcode exec 编码为 GBK(Windows) | Node 在 `spawn` 中默认使用 UTF-8;无需修复 | 已在 README 中记录为面向未来 Python 移植的坑 | @@ -692,15 +706,20 @@ standalone 边界都保持原状。第 1 / 第 2 / 第 3 层只适用于服务 模式(完整演练见 `docs/DEVELOPMENT.md`): 1. 创建 `server/routes/foo.js`,导出 `async function handleFoo(req, res, ctx, pathname)` -2. 在 `server/router.js` 中导入 -3. 添加到路由表: - ```js - { method: 'POST', match: (p) => p === '/api/foo', handler: fooRoute.handleFoo } - ``` -4. 如果新端点会修改状态,在处理器中调用 `pushStateFor(cid, {...})`。 - 绝不要直接写入 `clientState.state`。 -5. 如果该端点由 webui 调用,将其添加到 - `packages/webui/webapp/lib/api.ts` 中的 fetch 辅助函数(`API_SUFFIX` 会自动附加)。 +2. 注册到当前拥有它的那个层: + - 绝大多数端点由 **Hono 拥有**。在 `server/app.js` 的 `OWNED_ROUTES` + 中加入 `METHOD /api/foo` 字面量,并在那里接上 + `app.post("/api/foo", …)`。`OWNED_ROUTES` 就是 Hono 所服务内容的账本。 + - 遗留的 `ROUTES` 表(`server/router.js`)仍拥有一小部分端点 + (`/api/health`、`GET /api/events`、`GET /api/alerts`、 + `POST /api/settings`)以及静态资源与 `/trajectory/` 的处理。 + 只有当端点确实属于那里时,才添加 `{ method, match, handler }` 条目。 +3. 如果新端点会修改状态,在处理器中调用 `pushStateFor(cid, {...})`。 + 绝不要直接写入 `clientState` 对象。 +4. 如果该端点由 webui 调用,在 `packages/webui/webapp/lib/api.ts` 中 + 添加一个带类型的方法。它经本地的 `request()` 辅助函数发请求,该函数 + 自己会追加 `cid` 查询参数;并不存在 `API_SUFFIX` 常量——本文档早期 + 版本提到过,它已被移除。 ## 10. 未来方向 From 3e04dbe2a3701010d4c6701a0b1ebc9a70e20919 Mon Sep 17 00:00:00 2001 From: s39-dev Date: Wed, 30 Sep 2026 22:13:43 +0800 Subject: [PATCH 3/3] test(verify): teach the release-tools fixtures about the docs gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a gate to verify.mjs is not self-registering. The source-sync fixture stubs the direct-path scripts it executes, and two profiles assert their gate list by deepEqual, so both went red the moment check:docs-alignment joined: the fixture hit MODULE_NOT_FOUND and the lists disagreed on one more entry. ci-release.yml filters on scripts/verify.mjs, so touching the verifier is what pulled that workflow in — the failure was always going to surface there first. --- test/source-sync.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index 1d3784064..82a14d02e 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -632,6 +632,12 @@ function verificationFixture(t) { // than running the real checks against an empty build tree. mkdirSync(path.join(root, 'scripts/lib'), { recursive: true }); writeFileSync(path.join(root, 'scripts/check-webui-bundle.mjs'), `console.log('Web UI server bundle ok (fixture stub).');`); + // Same reasoning for the documentation-alignment gate, which verify.mjs + // also runs by direct path. Adding a gate is not self-registering: without + // this stub the fixture fails with MODULE_NOT_FOUND before it ever reaches + // the routing assertions. + mkdirSync(path.join(root, 'packages/webui/scripts'), { recursive: true }); + writeFileSync(path.join(root, 'packages/webui/scripts/check-docs-alignment.mjs'), `console.log('Documentation alignment ok (fixture stub).');`); const manager = path.join(directory, 'manager.cjs'); writeFileSync(manager, ` const fs = require('node:fs'); @@ -746,7 +752,7 @@ test('documentation and archive profiles preserve their required validation gate const full = f.run(['--list']).stdout.trim().split('\n'); const docs = f.run(['--profile', 'docs', '--list']); assert.equal(docs.status, 0, docs.stderr); - assert.deepEqual(docs.stdout.trim().split('\n'), ['check:source', 'check:tsconfig', 'export source preview', 'test:release-tools']); + assert.deepEqual(docs.stdout.trim().split('\n'), ['check:source', 'check:tsconfig', 'check:docs-alignment', 'export source preview', 'test:release-tools']); const archive = f.run(['--profile', 'archive', '--list']); assert.equal(archive.status, 0, archive.stderr); assert.deepEqual(archive.stdout.trim().split('\n'), full.filter(g => g !== 'export source preview')); @@ -974,6 +980,7 @@ test('Windows contract profile selects focused gates', () => { assert.deepEqual(result.stdout.trim().split('\n'), [ 'check:source', 'check:tsconfig', + 'check:docs-alignment', 'export source preview', 'test:release-tools', 'build',