diff --git a/.gitignore b/.gitignore index 4ad383bf..d6a8b9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ dist/ /scripts/verify_deploy.sh /scripts/kernel-stress/ /scripts/gui-package-smoke.sh + +# ── 本地 AI 工具的仓库级配置(每人偏好不同,不进仓库)── +.pi-lens.json diff --git a/.pi-lens.json b/.pi-lens.json deleted file mode 100644 index 82e293d3..00000000 --- a/.pi-lens.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "_comment": "pi-lens 仓库级配置。cmd/gui 的 test 排除原因见 docs/zh/ci-cd-runbook.md「诊断噪音」一节。", - "_why_cmd_gui_tests_excluded": "cmd/gui 是纯 Electron/Node 目录(0 个 .go 文件、无 go.mod)。pi-lens 的 test runner 在写文件时按仓库主语言(Go)为目录跑测试,而 cmd/gui/*.test.mjs 被识别成测试文件 ⇒ 它对一个纯 Node 目录执行 `go test ./cmd/gui`,必然得到 `no Go files ... [setup failed]`,却被报成「测试失败」。这些 .mjs 的真实运行方式是 `npm test`(CI 的 GUI job 已在跑),pi-lens 无法正确执行它们,故排除触发源。上游缺陷:runner 应先确认目录内有该语言的源文件。", - "ignore": [ - "cmd/gui/node_modules/**", - "cmd/gui/**/*.test.mjs", - ".npm-cache/**", - "build/**", - "dist/**", - "third_party/homeagent-sdk/site_build/**", - "third_party/homeagent-sdk/build/**", - "third_party/homeagent-sdk/.npm-cache/**" - ] -} diff --git a/docs/zh/ci-cd-runbook.md b/docs/zh/ci-cd-runbook.md index cf20fc0f..5cbabe79 100644 --- a/docs/zh/ci-cd-runbook.md +++ b/docs/zh/ci-cd-runbook.md @@ -256,7 +256,18 @@ turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first) 注意触发者是 `README.md` —— 目标是**与本次编辑无关**的陈旧失败项。 -### 6.3 为什么不能用项目级配置关掉 +### 6.3 为什么仓库里没有 `.pi-lens.json` + +pi-lens 的 `ignore` 是**本地工具配置**,随每个人的工具版本与偏好变化, +不该进仓库(它对 CI 与其他贡献者毫无意义,还会成为 merge 噪音)。 +需要的配置由各人在自己的机器上放一份即可。 + +★ 而**根因分析要留在仓库里** —— 就是本节 6.1/6.2: +上游缺陷(runner 不按目录语言选、failed-first 让误报永不自愈) +不知道哪年修,修之前每个新 clone 的人都会再踩一遍。 +所以是「配置外置、结论内留」。 + +### 6.4 为什么不能用项目级配置关掉 `.pi-lens.json` 是**项目级**,只认一小排键 (`ignore` / `rules` / `maxProjectFiles` / `reviewGraph` / `trivy` + 三个改动开关)。 @@ -271,7 +282,7 @@ turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first) 另:`ignore` 也挡不住,因为它只作用于扫描,不参与测试目标选择(`failed-first` 的回退分支根本不看候选文件)。 -### 6.4 修法:本机补丁(已打) +### 6.5 修法:本机补丁(已打) 补丁位置:`~/.pi/agent/npm/node_modules/pi-lens/dist/index.js`。 在 `getTestRunTarget` 返回目标前加一道校验: diff --git a/internal/plugins/webui/config_namespace_test.go b/internal/plugins/webui/config_namespace_test.go new file mode 100644 index 00000000..2201ffeb --- /dev/null +++ b/internal/plugins/webui/config_namespace_test.go @@ -0,0 +1,123 @@ +package webui + +import ( + "path/filepath" + "strings" + "testing" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" +) + +// 门禁:**全局 config 表里不得出现 plugin..* 形态的键**。 +// +// ## 要判的是什么(2026-10-05 生产清理) +// +// 生产 config.db 的全局 `config` 表里躺着: +// +// plugin.webui.username = admin +// plugin.webui.password = admin123 ← 明文口令 +// plugin.webui.api_key = admin123 ← 明文密钥 +// +// 而**生效凭证根本不在这张表**:`getWebUIConfig` 走 `h.settings.Get` +// → `PluginConfig("webui")` → `config_webui` 表,那里是 jianf/jinrui233719。 +// +// ⇒ 那三行是**旧版 SDK 的残留**(那时 Settings() 还写全局表)。 +// 现在没有任何代码路径会再写它们(已用判据钉住,见下), +// 但留在库里就是明文泄露:备份、日志、误 dump、任何能读 config.db 的人。 +// +// ★ 读不到 ≠ 不该存在。密钥类数据留在库里本身就是风险。 +// +// ## 判据钉的是「命名空间纪律」而非逐键列举 +// +// `plugin..` 形态的键只允许出现在 `config_` 表 +// (那是插件配置的合法位置)。全局表里出现即命名空间错误。 +// 比「这些键现在没被读」强:将来任何插件误写全局表都会被抓到。 + +// newCfgRegistry 建一个带默认播种的临时 registry。 +func newCfgRegistry(t *testing.T) *internalConfig.ConfigRegistry { + t.Helper() + dir := t.TempDir() + reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db")) + reg.SeedDefaults(dir) + t.Cleanup(func() { reg.Close() }) + return reg +} + +// TestPluginSettingsGoesToPluginTable 钉住「插件的 Set 落 config_,不是全局表」。 +// +// ★ 这条是正向断言,比「不许出现 plugin.* 键」更根本: +// +// 只要 Settings().Set 还写 config_,全局表就不会再长出 plugin.* 键。 +// 生产那三行残留正是因为历史上它曾经写全局表。 +func TestPluginSettingsGoesToPluginTable(t *testing.T) { + reg := newCfgRegistry(t) + + // ★ 必须先 RegisterDef:插件配置表(config_)由 RegisterDef 创建 + // (见 registry.go 的 ensurePluginTable),而插件 Start 时也是这么做的。 + // 直接 Set 会得到 "no such table: config_webui"——那不是缺陷, + // 是「表尚未随定义建立」的正常时序。 + ps := reg.PluginConfig("webui") + ps.RegisterDef(internalConfig.ConfigDef{ + Key: "password", Default: "", Type: "password", + }) + + const secret = "s3cret-should-not-be-in-global-table" + if err := ps.Set("password", secret); err != nil { + t.Fatalf("Set: %v", err) + } + + // ① 全局表里查不到。 + if v, _ := reg.Get("plugin.webui.password"); v != nil { + t.Errorf("插件配置泄漏进全局表:plugin.webui.password = %v", v) + } + // ② 插件表里能查到(证明 ① 不是「压根没写进去」)。 + if v, _ := ps.Get("password"); v != secret { + t.Errorf("插件表里读不到刚写的值:%v", v) + } + // ③ 全局 List 也不该暴露它。 + for _, k := range reg.List("") { + if strings.HasPrefix(k, "plugin.") { + t.Errorf("全局 List 暴露 plugin.* 形态的键 %q", k) + } + } +} + +// TestGlobalConfigHasNoPluginPrefixedKeys 在**播种了全部默认值**之后检查全局表。 +// +// ★ 与上一条同源但覆盖面更大:默认值播种(SeedDefaults)是「没人显式 Set +// +// 也会往库里写」的那条路径,最容易把带前缀的键漏进全局表。 +func TestGlobalConfigHasNoPluginPrefixedKeys(t *testing.T) { + reg := newCfgRegistry(t) + + for _, k := range reg.List("") { + if strings.HasPrefix(k, "plugin.") { + t.Errorf("SeedDefaults 后全局 config 表出现 plugin.* 形态的键 %q —— "+ + "插件配置必须写进 config_ 表;写进全局表会把口令/密钥"+ + "落到不该落的地方(生产曾残留 plugin.webui.password=admin123)", k) + } + } +} + +// TestRegisterDefDoesNotQualifyKey 钉住 RegisterDef 的键限定行为: +// +// `PluginSettings.RegisterDef` 会把 `def.Key` 改写成 `plugin..` +// 存进全局 defs 注册表(供设置页展示 meta),而**值**仍写 config_。 +// 这里确认「defs 注册表里有 plugin.* 键」是**预期**的, +// 免得后人把这条门禁误改成「全局不许出现 plugin. 前缀」而误伤正常元数据。 +func TestRegisterDefDoesNotQualifyKey(t *testing.T) { + reg := newCfgRegistry(t) + + ps := reg.PluginConfig("webui") + defs := ps.ListDefs("") + var sawQualified bool + for _, d := range defs { + if strings.HasPrefix(d.Key, "plugin.webui.") { + sawQualified = true + } + } + if !sawQualified { + // 没播种 webui 的 def 也可能为空 —— 那就不做断言,但要点明意图。 + t.Log("本次 registry 无 webui 配置定义(SeedDefaults 未含),跳过正向确认") + } +} diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 484c8362..045a474b 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -644,8 +644,24 @@ async function renderAll(force) { // force:变更操作后(保存设置/启停插件)需要重新拉那块数据。 if (force) _loadedOnce = {}; // 1) 只拉当前页签需要的数据(并发的别串行等) + // + // ★ 必须逐项容错(2026-10-05):之前这里是裸的 Promise.all, + // 任一 fetch 块抛错 ⇒ 整体 reject ⇒ **后面的 spec.render 根本不执行** + // ⇒ 整页白屏。真实案例:starmapFetchBlock("kernel") 里调用了 + // 一个作用域不对的函数(ReferenceError), + // 而 TABS 的 overview / plugins / kernel 三页都含 "kernel" ⇒ 三页全白。 + // + // 下面对照:第 2 步的 spec.render 有 typeof 守卫、星图有 try/catch, + // 唯独这一步没有。而它跑在 render **之前**,一旦炸后面全不执行。 var needs = spec.fetch || []; - await Promise.all(needs.map((n) => starmapFetchBlock(n))); + await Promise.all( + needs.map(function (n) { + // 单块失败只记日志,不影响同批其它块与后续渲染。 + return starmapFetchBlock(n).catch(function (e) { + console.error("starmapFetchBlock(" + n + ")", e); + }); + }), + ); // 2) 只渲染当前页签(+ 星图在多处出现,单独处理) try { spec.render.forEach((fn) => { @@ -3239,6 +3255,73 @@ function endChatTurn() { rerenderChat(true); } +// ★★ 用量徐标渲染(2026-10-05 从 connectSSE() 内提到顶层) +// +// 此前它定义在 connectSSE() 内部(缩进一个 tab),而 starmapFetchBlock 的 +// kernel 分支在 renderAll 里调用它 —— 那是**顶层**调用,此时 connectSSE +// 早已返回 ⇒ renderChatUsage 是 undefined ⇒ +// +// Uncaught (in promise) ReferenceError: renderChatUsage is not defined +// at starmapFetchBlock (dashboard.js) +// at async Promise.all (index 2) +// at async renderAll (dashboard.js) +// +// 而 renderAll 里 `await Promise.all(needs.map(starmapFetchBlock))` +// **没有 try/catch**,且 TABS 的 overview / plugins / kernel 三个页签都含 +// "kernel" ⇒ 任一页触发它,Promise.all 整体 reject,后面的 spec.render +// 根本不执行 ⇒ 整页白屏(生产实测)。 +// +// 教训与本仓其它「静默失效」同型:**声明位置就是契约**。 +// 判据见 dashboard_js_scope_test.go(node 真跑 + 缩进静态检查)。 +// 用量徐标渲染。 +// +// 口径纪律(与内核 usageLedger 同一套,不能各写一套): +// · cache_hit_rate **可能缺席** —— 那是「没有任何调用报过缓存细节」, +// 必须显示「—」而不是 0%。把「不知道」画成 0% 会让人去优化一个 +// 本来就没开的功能; +// · 缺席与「命中率为 0」是两回事,后者是有数据、真的一分没命中。 +function renderChatUsage(u, single, source) { + var el = document.getElementById("chat-usage"); + if (!el) return; + if (!u || !u.total) { + el.style.display = "none"; + return; + } + function compact(n) { + n = n || 0; + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n); + } + var hasRate = typeof u.cache_hit_rate === "number"; + el.textContent = + compact(u.total) + + " tok · " + + (hasRate + ? __("缓存 ", "cache ") + Math.round(u.cache_hit_rate * 100) + "%" + : __("缓存 —", "cache —")); + var tip = [source || "", + __("调用 ", "calls ") + (u.calls || 0), + "prompt " + (u.prompt || 0), + "completion " + (u.completion || 0), + "cache_read " + (u.cache_read || 0), + "cache_miss " + (u.cache_miss || 0)]; + tip.push( + hasRate + ? __("命中率 ", "hit rate ") + (u.cache_hit_rate * 100).toFixed(1) + "%" + : __( + "命中率未知(上游未报缓存细节)", + "hit rate unknown (upstream reported no cache)", + ), + ); + if (single && single.total) { + tip.push(__("本次 ", "last call ") + "prompt " + (single.prompt || 0) + + " / completion " + (single.completion || 0)); + } + el.title = tip.filter(Boolean).join("\n"); + el.style.display = ""; +} + // 回合看门狗:POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失), // 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 function armTurnWatchdog() { @@ -4465,54 +4548,6 @@ function connectSSE() { } }); - // 用量徐标渲染。 - // - // 口径纪律(与内核 usageLedger 同一套,不能各写一套): - // · cache_hit_rate **可能缺席** —— 那是「没有任何调用报过缓存细节」, - // 必须显示「—」而不是 0%。把「不知道」画成 0% 会让人去优化一个 - // 本来就没开的功能; - // · 缺席与「命中率为 0」是两回事,后者是有数据、真的一分没命中。 - function renderChatUsage(u, single, source) { - var el = document.getElementById("chat-usage"); - if (!el) return; - if (!u || !u.total) { - el.style.display = "none"; - return; - } - function compact(n) { - n = n || 0; - if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; - if (n >= 1000) return (n / 1000).toFixed(1) + "k"; - return String(n); - } - var hasRate = typeof u.cache_hit_rate === "number"; - el.textContent = - compact(u.total) + - " tok · " + - (hasRate - ? __("缓存 ", "cache ") + Math.round(u.cache_hit_rate * 100) + "%" - : __("缓存 —", "cache —")); - var tip = [source || "", - __("调用 ", "calls ") + (u.calls || 0), - "prompt " + (u.prompt || 0), - "completion " + (u.completion || 0), - "cache_read " + (u.cache_read || 0), - "cache_miss " + (u.cache_miss || 0)]; - tip.push( - hasRate - ? __("命中率 ", "hit rate ") + (u.cache_hit_rate * 100).toFixed(1) + "%" - : __( - "命中率未知(上游未报缓存细节)", - "hit rate unknown (upstream reported no cache)", - ), - ); - if (single && single.total) { - tip.push(__("本次 ", "last call ") + "prompt " + (single.prompt || 0) + - " / completion " + (single.completion || 0)); - } - el.title = tip.filter(Boolean).join("\n"); - el.style.display = ""; - } // token 级流式增量:逐块追加到当前回复内容(流式生成中); // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 es.addEventListener("content_delta", (e) => { diff --git a/internal/plugins/webui/dashboard_js_scope_test.go b/internal/plugins/webui/dashboard_js_scope_test.go new file mode 100644 index 00000000..6a6ff24e --- /dev/null +++ b/internal/plugins/webui/dashboard_js_scope_test.go @@ -0,0 +1,305 @@ +package webui + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// dashboard.js 的**作用域**判据:所有被跨函数调用的 render* 必须是顶层声明。 +// +// ## 要判的是什么(2026-10-05 生产实测白屏根因) +// +// Uncaught (in promise) ReferenceError: renderChatUsage is not defined +// at starmapFetchBlock (dashboard.js:3830) ← 顶层函数 +// at async Promise.all (index 2) +// at async renderAll (dashboard.js:3878) +// +// renderChatUsage 定义在 dashboard.js:4475,**缩进是一个 tab**, +// 位置在 connectSSE()(4342 行)内部 ⇒ 它只在 connectSSE 执行期间处于作用域内。 +// +// 而 starmapFetchBlock 是**顶层函数**,在 renderAll 里与其它 fetch 并发跑, +// 那时 connectSSE 早已返回 ⇒ renderChatUsage 是 undefined ⇒ ReferenceError。 +// +// ★ 为什么这会导致「大量页面白屏」而不是「少一个数字」: +// +// renderAll 里 `await Promise.all(needs.map(starmapFetchBlock))` +// **没有 try/catch**,而 fetch 列表里 overview/plugins/kernel 三个页签 +// 都含 "kernel" ⇒ 任何一页触发它,Promise.all 整体 reject, +// **后面的 spec.render.forEach 根本不执行** ⇒ 整页空白。 +// +// renderAll 明明给 render 步骤写了 `typeof window[fn] === "function"` 守卫、 +// 也给星图写了 try/catch,唯独漏了 fetch 这一步 —— 而它跑在 render 之前。 +// +// ★ 为什么这类 bug 能活下来: +// - Go 侧测试只验 dashboard.html/js/css 的**组装**(TestDashboardAssetsSplit), +// 不验 JS 语义 ⇒ 编译、vet、单测全绿。 +// - 它只在**运行期、且 state.kernel.usage 存在**(即 /kernel 返回了用量)时触发。 +// 新装实例 usage 为 0/null 时不炸 ⇒ 装完看着好好的,跑几天后才白屏。 +// +// 判据做法:用 node 在沙箱里把 dashboard.js 跑一遍,抓未捕获异常。 +// node 不在时 t.Skip(CI 里 GO 环境可能没有 node)。 + +// dashboardJSSource 取出待测脚本文本(测试内复用以免重复读 embed)。 +func dashboardJSSource(t *testing.T) string { + t.Helper() + b, err := dashboardFS.ReadFile("dashboard.js") + if err != nil { + t.Fatalf("读 dashboard.js: %v", err) + } + return string(b) +} + +// TestDashboardJS_HasNoReferenceErrorInStrictMode 用 node 跑真脚本抓 ReferenceError。 +// +// ★ 为什么必须真跑而不是 grep「函数名出现过几次」: +// +// 文本上 renderChatUsage 出现了 2 次(定义 + 调用),grep 查不出作用域问题。 +// 作用域只能在真执行时暴露 —— 这是本仓反复记的「测真实行为而非形态」。 +func TestDashboardJS_HasNoReferenceErrorInStrictMode(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("无 node,跳过 JS 语义判据") + } + js := dashboardJSSource(t) + // 触发 kernel 分支里的 renderChatUsage 调用:造一个带 usage 的 /kernel 响应, + // 然后调 starmapFetchBlock("kernel")。 + // + // ★★ 桩必须建在 dashboard.js **之前**(否则整个脚本不执行,判据变成「测不到」): + // ① 文件顶部有立即执行的 IIFE(initAppearance / 主题应用), + // 它们访问 localStorage / document.body / window.innerWidth / matchMedia。 + // ② 文件末尾有 setInterval(renderAll, 15000) 与 setInterval(sync…, 30000), + // **不接管 setInterval 的话 node 永不退出** —— 实测表现为 + // exit 0 且 stdout 全空,看起来像「脚本没报错」,实则根本没跑到断言。 + // 这条踩坑本身就是判据设计的坑:静默 hang 会被误读成通过。 + harness := ` +const __caught = []; +process.on('uncaughtException', (e) => { __caught.push(String(e)); }); +process.on('unhandledRejection', (e) => { __caught.push('Rejection: ' + String(e)); }); +const __store = {}; +globalThis.localStorage = { + getItem: (k) => (k in __store ? __store[k] : null), + setItem: (k, v) => { __store[k] = String(v); }, + removeItem: (k) => { delete __store[k]; }, +}; +globalThis.matchMedia = () => ({ matches: false, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){} }); +globalThis.innerWidth = 1400; +globalThis.innerHeight = 900; +globalThis.addEventListener = () => {}; +globalThis.requestAnimationFrame = (f) => setTimeout(f, 16); +globalThis.cancelAnimationFrame = () => {}; +// ★ 接管定时器:否则永不退出(见上)。 +globalThis.setInterval = () => 0; +globalThis.clearInterval = () => {}; +const __mkEl = () => ({ + style: {}, dataset: {}, children: [], + classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, + setAttribute(){}, getAttribute(){ return null; }, + appendChild(c){ this.children.push(c); return c; }, + removeChild(){}, insertAdjacentHTML(){}, remove(){}, addEventListener(){}, + removeEventListener(){}, querySelector(){ return null; }, querySelectorAll(){ return []; }, + getBoundingClientRect(){ return { width: 0, height: 0, left: 0, top: 0 }; }, + focus(){}, click(){}, closest(){ return null; }, +}); +globalThis.document = { + documentElement: { setAttribute(){}, classList:{ add(){}, remove(){}, toggle(){} }, style:{ setProperty(){} } }, + head: __mkEl(), body: __mkEl(), + createElement: __mkEl, createTextNode: (t) => ({ textContent: t }), + // ★ getElementById 必须返回**可用元素**而不是 null: + // dashboard.js 末尾有顶层调用 renderConfigDisabled()(7129 行), + // 它对 getElementById 的结果写 .innerHTML。返回 null ⇒ TypeError + // ⇒ node 在跑到断言之前就退出,**stdout 全空、exit 0**, + // 看起来像「没报错」,实则一条断言都没执行 —— 静默假绿。 + getElementById: () => __mkEl(), + querySelector: () => __mkEl(), querySelectorAll: () => [], + getElementsByClassName: () => [], getElementsByTagName: () => [], + addEventListener(){}, removeEventListener(){}, cookie: '', +}; +globalThis.window = globalThis; +globalThis.__ = (zh) => zh; +globalThis.navigator = { language: 'zh-CN', userAgent: 'node', clipboard: { writeText: async () => {} } }; +globalThis.location = { href: 'http://x/', origin: 'http://x', protocol: 'http:', hostname: 'x', reload(){}, assign(){} }; +globalThis.history = { replaceState(){}, pushState(){} }; +globalThis.EventSource = class { constructor(){} addEventListener(){} close(){} }; +` + js + ` +// ① 让 api() 直接返回带 usage 的 kernel,避开网络。 +// ★ content-type 必须是 application/json:api()(dashboard.js:414)先看 +// r.headers.get("content-type"),含 "json" 才走 r.json(); +// 否则走 r.text() ⇒ state.kernel 变成字符串 ⇒ kernel 分支的 +// "if (state.kernel && state.kernel.usage)" 为假 ⇒ 压根走不到那一行, +// 判据变成「测不到」(实测 state.kernel = "")。 +globalThis.fetch = async () => ({ + ok: true, status: 200, + json: async () => ({ usage: { total: 42, prompt: 10, completion: 32 } }), + text: async () => '', + headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? 'application/json' : null) }, +}); +(async () => { + try { + await starmapFetchBlock('kernel'); + } catch (e) { + console.log('THROWN:' + String((e && e.stack) ? e.stack.split('\\n')[0] : e)); + process.exitCode = 3; + return; + } + await new Promise((r) => setTimeout(r, 60)); + if (__caught.length) { + console.log('CAUGHT:' + JSON.stringify(__caught)); + process.exitCode = 3; + } else { + console.log('OK'); + } +})(); +` + // ★ 必须写临时文件而不是 node -e:dashboard.js 有 500KB+, + // 内联进命令行会撞 ARG_MAX(实测 "argument list too long")。 + tmp := filepath.Join(t.TempDir(), "harness.js") + if err := os.WriteFile(tmp, []byte(harness), 0o644); err != nil { + t.Fatal(err) + } + out, err := exec.Command("node", tmp).CombinedOutput() + o := string(out) + t.Logf("node 输出: %s", strings.TrimSpace(o)) + // ★ 任何形式的运行期异常都算失败,包括 harness 自己报出的 THROWN:。 + // + // 先前这里只认 "CAUGHT:",而 harness 是用 try/catch 包住 + // starmapFetchBlock 并打印 "THROWN:" 的 ⇒ 真实缺陷被判成 + // 「非本次目标」而 Skip,**又是一次假绿**。 + // 判据自己的报告格式不能成为它的免罪符。 + if strings.Contains(o, "THROWN:") || strings.Contains(o, "CAUGHT:") { + t.Fatalf("dashboard.js 运行期抛错(页面会白屏):\n%s", strings.TrimSpace(o)) + } + if err != nil { + if strings.Contains(o, "OK") { + return + } + t.Fatalf("node 执行失败(判据自身的问题,不能 Skip ——\n"+ + "Skip 会把「测不到」伪装成「通过」):%v\n%s", err, strings.TrimSpace(o)) + } + if !strings.Contains(o, "OK") { + t.Fatalf("node 跑完但没到断言点(又是静默假绿):\n%s", strings.TrimSpace(o)) + } +} + +// TestDashboardJS_RenderHelpersAreTopLevel 静态钉死作用域: +// 凡出现在 TABS[].render 或被 starmapFetchBlock 调用的 render*,缩进必须是 0。 +func TestDashboardJS_RenderHelpersAreTopLevel(t *testing.T) { + js := dashboardJSSource(t) + lines := strings.Split(js, "\n") + + // 找出所有形如 `\t*function NAME(` 的声明,记录缩进深度。 + depth := map[string]int{} + for _, l := range lines { + t2 := strings.TrimLeft(l, "\t") + if !strings.HasPrefix(t2, "function ") && !strings.HasPrefix(t2, "async function ") { + continue + } + rest := t2[strings.Index(t2, "function ")+len("function "):] + name := strings.TrimSpace(strings.SplitN(rest, "(", 2)[0]) + name = strings.TrimPrefix(name, "async ") + depth[name] = len(l) - len(t2) // tab 数 + } + + // 这些必须顶层:TABS 里声明的 render 名 + starmapFetchBlock 里直接调用的。 + mustTop := map[string]string{ + "renderChatUsage": "starmapFetchBlock 的 kernel 分支调用它(dashboard.js:3830)", + "renderOverview": "TABS.overview.render", + "renderPlugins": "TABS.plugins.render", + "renderKernel": "TABS.kernel.render", + "renderChat": "TABS.chat.render", + "renderAdapters": "TABS.adapters.render", + } + for name, why := range mustTop { + d, ok := depth[name] + if !ok { + t.Errorf("%s 未找到声明(%s)", name, why) + continue + } + if d != 0 { + // 报告行号 + line := 0 + for i, l := range lines { + t2 := strings.TrimLeft(l, "\t") + if strings.HasPrefix(t2, "function "+name+"(") || + strings.HasPrefix(t2, "async function "+name+"(") { + line = i + 1 + break + } + } + t.Errorf("%s 声明在缩进 %d 处(dashboard.js:%d),必须顶层(缩进 0)——"+ + "否则顶层函数调用它时是 undefined:%s", name, d, line, why) + } + } +} + +// TestRenderAll_GuardsEveryStep 钉住 renderAll 的每一步都有兜底。 +// +// ★ 这是白屏能扩大的**结构性原因**:fetch 那一步裸奔, +// +// 一个 ReferenceError 就让整页什么都不渲染。 +func TestRenderAll_GuardsEveryStep(t *testing.T) { + js := dashboardJSSource(t) + start := strings.Index(js, "async function renderAll(") + if start == -1 { + t.Fatal("找不到 renderAll") + } + // 取到下一个顶层 "\n}\n" 为止。 + end := strings.Index(js[start:], "\n}\n") + if end == -1 { + t.Fatal("renderAll 提取失败") + } + body := js[start : start+end] + + // Promise.all 那一步必须有容错。 + // + // ★ 两种等价写法都算合格,不要把判据钉死成某一种: + // + // ① 逐项 catch:needs.map(n => starmapFetchBlock(n).catch(...)) + // ② 整体 try/catch:try { await Promise.all(...) } catch {} + // + // 先前这条判据只认 ②(找字面 "try {"),而修法用的是 ① ⇒ + // 修好了判据却变红。**判据必须钉「语义」不钉「写法」**, + // 否则它会惩罚正确的实现。 + pa := strings.Index(body, "Promise.all") + if pa == -1 { + t.Fatal("renderAll 里没有 Promise.all,结构变了?") + } + before := body[max(0, pa-400):pa] + after := body[pa:min(len(body), pa+900)] + + // ① 逐项 catch:在 map 回调里对每个 promise 接 .catch(。 + // 窗口要够大:真实写法里 .catch 距 Promise.all 约 200+ 字符 + // (中间隔着 needs.map(function (n) { … })),窗口太小会漏判。 + hasPerItemCatch := strings.Contains(after, ").catch(") || + strings.Contains(after, ").catch(function") || + strings.Contains(after, ".catch((e)") || + strings.Contains(after, ".catch(function") + // ② 整体 try/catch:Promise.all 前后有 try { … } catch。 + hasTryCatch := (strings.Contains(before, "try {") || strings.Contains(before, "try{")) && + strings.Contains(after, "catch") + + if !hasPerItemCatch && !hasTryCatch { + t.Errorf("renderAll 的 Promise.all(fetch 步骤)没有容错 ——" + + "任一 fetch 块抛错会让 Promise.all 整体 reject,**后面的 spec.render 根本不执行**" + + " ⇒ 整页白屏(生产实测:starmapFetchBlock(\"kernel\") 里的 ReferenceError" + + " 连带 overview/plugins/kernel 三页全白)。\n" + + "★ 合格写法二选一:逐项 .catch(...),或整体 try/catch。" + + "参考同函数里 spec.render 已有的 typeof 守卫 —— 那一步一直有兜底," + + "唯独跑在它之前的 fetch 步骤没有。") + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/plugins/webui/handler_graph_pulse_test.go b/internal/plugins/webui/handler_graph_pulse_test.go index cfa4b3e8..2b63d95d 100644 --- a/internal/plugins/webui/handler_graph_pulse_test.go +++ b/internal/plugins/webui/handler_graph_pulse_test.go @@ -43,9 +43,17 @@ func graphFixture() map[string]interface{} { "created_at": now, "updated_at": now}, }, "edges": []map[string]interface{}{}, + // ★ memory_blocks 才是 pulse 的过滤源(2026-10-05 修)。 + // 刻意放两个:一个 8h 前(默认 900s 窗口外)、一个刚更新(窗口内)。 + // 旧节点(nodes/小宅)的 updated_at 也刻意设成 8h 前 —— + // 修复前 pulse 读的是它,而它永远落在窗口外 ⇒ 恒返回空。 "memory_blocks": []memory.MemoryBlock{ - {ID: "b1", Modality: memory.BlockModality("text"), Text: "hi", - PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now}, + {ID: "b_old", Modality: memory.BlockModality("text"), Text: "老块", + PayloadDigest: "d0", Vector: []float64{0.1, 0.2, 0.3}, + CreatedAt: old, UpdatedAt: now.Add(-8 * time.Hour)}, + {ID: "b_new", Modality: memory.BlockModality("text"), Text: "刚学到的东西", + PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, + CreatedAt: now, UpdatedAt: now}, }, } } @@ -69,7 +77,7 @@ func TestHandleMemoryGraph_StripsVector(t *testing.T) { t.Fatalf("response still contains vector field:\n%s", rr.Body.String()) } // 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。 - for _, want := range []string{`"payload_digest":"d1"`, `"id":"b1"`, `"nodes"`, `"edges"`} { + for _, want := range []string{`"payload_digest":"d1"`, `"id":"b_new"`, `"nodes"`, `"edges"`} { if !bytes.Contains(rr.Body.Bytes(), []byte(want)) { t.Fatalf("response missing %s:\n%s", want, rr.Body.String()) } @@ -91,9 +99,8 @@ func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) { Success bool `json:"success"` Data struct { Nodes []struct { - ID int64 `json:"id"` - Name string `json:"name"` - MentionCount int `json:"mention_count"` + ID string `json:"id"` + Name string `json:"name"` } `json:"nodes"` } `json:"data"` } @@ -107,8 +114,8 @@ func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) { if len(resp.Data.Nodes) != 1 { t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes) } - if resp.Data.Nodes[0].ID != 2 { - t.Fatalf("expected node id=2, got %d", resp.Data.Nodes[0].ID) + if resp.Data.Nodes[0].ID != "b_new" { + t.Fatalf("expected block id=b_new, got %q", resp.Data.Nodes[0].ID) } } @@ -121,7 +128,7 @@ func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) { var resp struct { Data struct { Nodes []struct { - ID int64 `json:"id"` + ID string `json:"id"` } `json:"nodes"` } `json:"data"` } @@ -161,3 +168,69 @@ func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) { t.Fatalf("nodes dropped") } } + +// TestGraphPulse_IgnoresFrozenLegacyNodes 钉住 pulse 的**过滤源**是块表而非旧表。 +// +// ★ 这条是 2026-10-05 清理时补的,针对一个真实的生产静默失效: +// +// /memory/graph/pulse 恒返回 {"nodes":[]}, +// 而 /memory/graph 同一时刻有 1294 个节点。 +// +// 根因:pulse 原实现按 `out["nodes"]` 的 updated_at 过滤,而 nodes 来自 +// **旧表 entities**(graph.go:1445)——旧表停双写后不再增长, +// 生产最后更新停在 2026-10-03,而 pulse 默认窗口只有 900s +// ⇒ 1294 个节点**永远**落在窗口外 ⇒ 恒空。 +// 症状形态:HTTP 200 + success:true,无任何报错。 +// +// ★ 为什么上面两条判据抓不住:它们的 fixture 里 nodes 与 memory_blocks +// +// 「恰好都有一条落在窗口内」,所以读哪个源都能过。 +// 这条刻意让**旧表节点全部落在窗口外、块有一个在窗口内**, +// 于是「读错源」与「读对源」的结果**必然不同**。 +func TestGraphPulse_IgnoresFrozenLegacyNodes(t *testing.T) { + now := time.Now() + frozen := now.Add(-72 * time.Hour) // 旧表:三天前,之后再没更新过 + + fixture := map[string]interface{}{ + // 旧表节点:全部在窗口外(模拟停双写后的冻结状态) + "nodes": []map[string]interface{}{ + {"id": 1, "name": "旧实体", "type": "Concept", "mention_count": 228, + "created_at": frozen, "updated_at": frozen}, + }, + "edges": []map[string]interface{}{}, + // 块表:有一条刚更新(活图谱的真实形态) + "memory_blocks": []memory.MemoryBlock{ + {ID: "b_live", Modality: memory.BlockModality("text"), Text: "刚写入的块", + PayloadDigest: "dx", CreatedAt: now, UpdatedAt: now}, + }, + } + + h := newGraphHandler(&fakeGraphMemory{data: fixture}) + rr := httptest.NewRecorder() + h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)) + + var resp struct { + Success bool `json:"success"` + Data struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"data"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\n%s", err, rr.Body.String()) + } + if !resp.Success { + t.Fatal("success = false") + } + if len(resp.Data.Nodes) != 1 { + t.Fatalf("旧表节点已冻结三天、块表有一条刚更新 ⇒ 应回 1 个(来自块表),"+ + "实际 %d 个:%+v\n★ 若为 0,说明 pulse 又退回读旧表了 —— "+ + "生产上这表现为「星图不跟随 agent 动」且无任何报错", + len(resp.Data.Nodes), resp.Data.Nodes) + } + if resp.Data.Nodes[0].ID != "b_live" { + t.Fatalf("应回块表的 b_live,实际 %q", resp.Data.Nodes[0].ID) + } +} diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index 2eea2297..fb8dfe1e 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -235,29 +235,43 @@ func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) return } out := graphDataForVisual(data) - // 过滤出窗口内变动过的节点。GraphData 的 nodes 是 []graphEntity(私有类型), - // 这里用重新序列化的方式裁剪:字段少、无向量、且不依赖私有类型断言。 - // 成本是「再序列化一次节点」,但相比把 400KB 发出去仍然划算得多。 - rawNodes, _ := json.Marshal(out["nodes"]) - var nodes []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - MentionCount int `json:"mention_count"` - UpdatedAt time.Time `json:"updated_at"` - } - _ = json.Unmarshal(rawNodes, &nodes) + // 过滤出窗口内变动过的节点。 + // + // ★★★ 必须按 **memory_blocks** 过滤(2026-10-05 修) + // + // 原实现取 `out["nodes"]`,而 GraphData 的 nodes 是从**旧表 entities** 读的 + // (memory/graph.go:1445 `SELECT ... FROM entities ORDER BY mention_count DESC`)—— + // 旧表在停双写后不再增长,生产里最后更新停在 2026-10-03。 + // 而 pulse 的窗口默认 900s(15 分钟)⇒ **1294 个节点永远落在窗口之外** + // ⇒ 恒返回 {"nodes":[]}。 + // + // ★ 症状形态与本仓那批「静默失效」完全一致:200 + success:true, + // 无任何报错,前端只是「星图不跟着 agent 动」。 + // + // 为什么该按块过滤:星图渲染用的就是 memory_blocks + // (graphDataForVisual 返回的 memory_blocks 是节点来源), + // 且块表是活的(生产最新写入 = 当天)。 + // + // 用块做过滤后仍保持**轻量**响应:不返回整个图谱,只回窗口内变动的块。 + rawBlocks, _ := json.Marshal(out["memory_blocks"]) + var blocks []graphBlockView + _ = json.Unmarshal(rawBlocks, &blocks) pulse := make([]map[string]interface{}, 0, 8) - for _, n := range nodes { - if n.UpdatedAt.Before(since) { + for _, b := range blocks { + if b.UpdatedAt.Before(since) && b.CreatedAt.Before(since) { continue } pulse = append(pulse, map[string]interface{}{ - "id": n.ID, - "name": n.Name, - "type": n.Type, - "mention_count": n.MentionCount, - "updated_at": n.UpdatedAt, + "id": b.ID, + "name": b.Text, + "modality": b.Modality, + "payload_digest": b.PayloadDigest, + "mime": b.MIME, + "tool": b.Tool, + "scene": b.Scene, + "source": b.Source, + "updated_at": b.UpdatedAt, + "created_at": b.CreatedAt, }) } writeJSON(w, http.StatusOK, map[string]interface{}{