diff --git a/README.md b/README.md index 72a770b..c38b565 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -207,7 +207,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 1627 个测试 +npm test # 1651 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -414,7 +414,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 1627 tests +npm test # 1651 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 99664f9..0dafa8a 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -6,6 +6,8 @@ - **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、请求体逐字只有 type/title/content/importance/tags、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。评审期补的两处:**成功路径不得重置 `busy`**——提示要停 900ms 才关窗,那段时间按钮若重新可点,第二次同 type+title 会落进同一行并由 `appendContent` 把正文再追加一遍,提示还会从「已新建」翻成「已并入」,一次误触就写坏一条记忆;因此整个弹层只允许错误路径重置 `busy`,按钮文案也随之改成 `busy && !msg`,免得写完了还写着「保存中…」。另把字段锁从「整包里包含这些词」改成在 `CreateDialog` 源码切片上匹配真实请求体写法(在整包上断言 `clientSource.includes("title")` 恒真,等于没锁)。两侧都做了变异检验:面板/后端逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「成功路径放回 setBusy(false)」「按钮文案只看 busy」「表单长回 scope」「不 trim title」「打错路由」这类只有肉眼才能发现的退化)。 +- **面板存储回收入口(issue #386 第 4 条)**:库文件只增不减,回收一直只能靠命令行(`dsh-mneme-maintenance`)或独立服务的数据面,面板侧连「现在能收多少」都看不见。状态页新增「维护」分区与「存储回收」卡:挂载即 GET 一次预估,摆出文件体积、空闲页、本次会清掉多少条输入快照与多少条归档向量;改窗口后必须点「重新估算」,数字与输入框不一致时执行钮保持禁用——对不可逆动作来说,确认态里摆着另一个窗口的数字是最坏的一种错位。执行是**两步确认**:第一次点击只进确认态(换成仓库既有的实心红 `mneme-btndangerconfirm`),确认态里把窗口与 VACUUM 状态一并锁死显示,第二次点击才发出请求,请求体逐字 `{dryRun:false, confirm:true, olderThanDays:Number(days), vacuum}`。**VACUUM 默认关**:它会短暂独占数据库(别的进程此时写入会 `SQLITE_BUSY`),属可选动作,默认开等于每次回收都替用户独占一次库。**VACUUM 失败不得渲染成干净的成功**:两个 UPDATE 各自提交且不可逆,VACUUM 只负责把文件缩回去,半成功态单独出文案说明「清理已完成、不可逆、重跑幂等」,并且无论成败都露出回执号(缺失时明写「未记录」)——不可逆动作没有回执,出事时无法对账。面板侧不使用 `window.confirm`(宿主 webview 里不可靠),确认门是渲染出来的分支而不是阻塞弹窗。后端新增路由 `GET/POST /api/dsh-mneme/maintenance/reclaim`,底下就是 `src/maintenance.js` 那一份与 CLI、standalone 共用的实现,**未改动数据面**(`src/api-standalone.js`、`src/maintenance.js` 零改动):GET 恒为只读预估;POST 默认 dry-run,要真执行必须带 `dryRun:false` + `confirm:true`,否则回 400 `confirm-required` 并**附上那份 dry-run 报告**(确认的依据就是报告里的数字,且报告必须描述「将要执行的那个动作」——带了 `vacuum` 就得看到会 VACUUM 的那份,否则用户照着「不 VACUUM」的估算按下去)。两个方法都要鉴权:这是不可逆内容丢弃的入口,不能有例外。窗口解析在两侧**刻意不同口径**:GET 是 query 字符串域,只收十进制整数字面量(`/^\d+$/`),**不用 `Number()`**——它把空串、空数组、纯空格都化成 0,而 0 在这里的含义是「清掉全部快照」,最坏解释绝不能从一个畸形输入静默拿到;POST 收 JSON 数字域,字符串 `"3"`、`-1`、`3.5`、`true`、`{}`、`[]` 一律 400(`NaN` 不在坏输入列表里:JSON 表达不了它,`stringify` 会把它变成 `null`,而 `null` 与缺省同义、落默认窗口)。边界值 0 两侧都合法。`invalid-json` 与 `invalid-body` 分开报(前者查调用方序列化坏了,后者查把数组当 body 发了),不用会吞成 `{}` 的 `parseBody`。报告**不加包装层**,字段就在顶层——面板与 standalone 两个客户端必须看到同一个字段路径。接线:`createApi` 增第 9 个参数 `maintenance`,`src/index.js` 把实例构造前移到 webServer 块之前并同时传给面板与 standalone(构造晚了面板路由拿到 `null`,功能「看起来加了」但每次都回 503;少传则如实报 503 `maintenance-unavailable` 而不是把 `undefined.reclaim` 落成崩溃,既有 14 处不传第 9 参的调用照旧工作)。回归锁 14 条进 `test/api-routes-gaps.test.js`(鉴权、GET 只读且畸形窗口不落最坏解释、POST 默认 dry-run 且三个改库原语一个都没被调用、确认门回带同窗口报告、执行真清且行数不变只置列、非布尔 vacuum、invalid-json 与 invalid-body 分开、POST 数字域、缺依赖 503、抛错映射 500 且 logger 留住原始错误文本、非 GET/POST 落 404、报告无包装层、执行时 VACUUM 真的跑、确认报告复述调用方要的窗口),面板侧 8 条进 `test/client.test.js`(32 个 i18n 键中英成对且分区真的渲染、路由是 in-process 那条而非数据面、请求体逐字含确认门且全卡只有一处 POST、无 `window.confirm`、卡片真的挂在状态页维护分区下、成功态只在确认门另一侧且 stale 时禁用、VACUUM 失败与回执如实渲染、VACUUM 默认关且非 2xx 带出服务端 error 串且成功后复位确认态)。**独立验证**:第三方复核方独立重做了实现者报的全部 19 条后端与 12 条面板变异(全部 RED),并**额外找出 5 条契约硬要求没被任何锁钉住的覆盖洞**(最严重的是「VACUUM 默认关」——危险默认没有任何测试守住),已逐条补锁并同样做变异检验转红;另有一处诚实记录:去掉 GET 内层 catch 后**只断言状态码抓不住**(外层 catch 也兜 500),必须靠注入 logger 断言原始错误文本留住才算真锁住。**评审期(PR #410)另修三处**:① `olderThanDays` 补**上界** `MAX_RECLAIM_WINDOW_DAYS = 3650`(与 `llmAudit.retentionDays`、`recallRetentionDays` 同值),GET 与 POST 两侧统一——整数校验挡不住量级,`1e20` 也是整数,而 `maintenance.reclaim` 会拿它算 `now() - days * 86400000` 再 `toISOString()`,超出 `Date` 有效范围就抛 `RangeError`;GET 侧还要额外挡 `Number.isFinite`,否则 400 位十进制字面量化成 `Infinity` 会被当成畸形输入静默落回默认窗口 7——调用方要一个巨大窗口,实际只清了 7 天外的东西,且报告上写着 7 看不出异样。② `confirm-required` 分支里那份 dry-run 报告挪进 `try/catch`:它在 `readBody` 的回调里计算,外层 try 那时已经退栈,抛出去就是一个没人接的 rejection——调用方拿到的是「一个字节都没回」,而不是执行路径给出的 500 `internal`。③ 面板「未记录」文案改成「未记录(审计未启用或回执写入失败)」:`writeReceipt` 返回 null 有两种成因(审计被关、或回执写库失败只 warn),旧文案把后者说成前者,而这行出现在不可逆操作的结果区,用户会照它去对账。面板同时补上本地窗口上界(超界直接提示窗口不合法,不发请求,免得用户把 400 看成网络故障)。补锁三条并逐条变异转红(GET/POST 两侧量级、confirm-required 抛错映射 500、文案必须同时点出两种成因)。 + ## 🐛 修复 - **面板文字对比度全面达标 WCAG AA(issue #178)**:面板有 270 条 `color` 声明,此前 **70 条**在浅色或深色主题下达不到 AA(正文 4.5:1、大字号 3:1;浅色 65 条、深色 5 条,最差的两条 `.mneme-statfoot` 只有 1.26 / 1.64)。根因是面板直接拿宿主的**标签层** token 当正文色(`label-tertiary` 50 处、`label-dimmed` 4 处):这些是给分隔线/占位符用的低对比层,不是正文层。另有三处**图形**同病(不在文字口径内,按 3:1 复核):热度条与热度点的冷档用 `label-dimmed` 压在热度轨道上只有 1.14:1、关系图连线描边 1.26:1,等于把三档热度画成同一个看不见的灰。修法不是逐条换颜色,而是在四个渲染根上定义**面板自己的语义色层**:`.mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry{…}` 六条 `--mneme-*`(`fg-dim` / `graphic-dim` / `accent` / `danger` / `warn` / `ok`),每条都用 `color-mix()` 从宿主 token 混出——`fg-dim` = `label-primary` 60% + 透明(浅 4.94 / 深 6.30),其余同法。**四个根一个都不能少**:`.mneme-overlay` 与 `.mneme-topentry` 是 `createPortal` 出来的独立子树,`.mneme-triggerwrap` 挂在宿主 sidebar footer 插槽,只挂 `.mneme-x` 的话这三个根拿不到变量、静默退回旧色。顺带修掉 **5 个幻影 token**(`--dsw-alias-state-error` 19 处、`state-warning` 14 处、`state-success` 3 处、`border` 2 处、`state-warn` 1 处):这些名字宿主从未定义过,一直靠 CSS 兜底字面量在跑,宿主改配色时面板不会跟——现全部换成真名(`state-error-primary` / `static-amber-600` / `border-l1` 等)。另需分清:`label-dimmed`(4 处)与 `label-tertiary`(50 处)**都是真实存在的宿主 token**,问题不在名字而在用法——宿主把它们定位成分隔线/占位符的低对比层,面板却拿来当正文与图形色。三处 knockout 配色一并修:`.mneme-conflict-primary` 浅色主题下白字压品牌蓝 `#4176e6` 只有 4.23:1,底色换成 `--mneme-accent`(浅 5.32 / 深 7.67);`.mneme-entrybadge` 深色主题下 `bg-layer-1` 压 `#c33` 只有 3.06:1,底色换成 `--mneme-danger`;`.mneme-btndangerconfirm` 底色同步换成 `--mneme-danger`,并把写死的 `#fff` 换成 `var(--dsw-alias-bg-layer-1)`——它旧的写死白字只在旧底色上侥幸过关,底色一变浅就会在深色主题掉到 2.67:1。回归锁 `test/contrast.test.js` 五条:①把宿主 token 的浅/深取值快照硬编码进测试(CI 上没有宿主包,读不到 asar),逐条重算全部 `color` 声明的对比度,两主题都必须过,**解析不出的颜色记失败而不是跳过**(否则改成 `var(--mneme-unknown)` 就能静默溜过);②面板引用的每个 `--dsw-*` 必须在快照表里存在(幻影名回归锁);③语义色层的选择器必须恰好是那四个根(少了根主锁照样全绿——主锁只算颜色,不知道变量有没有继承到);④图形色(`background` / `stroke`,非 `color`)单独按 3:1 复核——热度条、热度点、打卡点、关系图连线都不在主锁的视野里;⑤虚线关系边不得靠 `opacity` 区分(无 `memory_id` 的边同时命中 `.mneme-gedge` 与 `.mneme-gedge-dashed`,后者的 `opacity:.7` 把 50% 描边压到浅色 2.28,而图形锁只读单条规则、看不见这种叠加)。变异检验七条:改淡 `fg-dim`、一处正文用回宿主标签色、塞回幻影名、渲染根少挂一个、热度冷档改回宿主 `label-dimmed`、一条 `color` 改成解析不出的 `var(--mneme-unknown)`、虚线边加回 `opacity:.7`,七条锁分别转红。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 0eaa085..f946920 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1627%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1651%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -613,7 +613,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1627 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1651 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -622,7 +622,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1627 个测试 +npm test # 运行 1651 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index b149ae5..a96434f 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -33,6 +33,12 @@ function sendAttachment(res, status, contentType, filename, body) { // defaults in config.js (externalApiPort / externalApiHost). const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" }; +// 回收窗口(天)的上界。maintenance.reclaim 拿这个数算 `now() - days * 86400000` 再 +// toISOString():日期一旦超出 Date 的有效范围就抛 RangeError。整数校验挡不住量级—— +// 1e20 也是「整数」,JSON 里还能写成 400 位数字。所以显式钉一个上界,取值与既有保留 +// 窗口配置一致(config.js 的 llmAudit.retentionDays / recallRetentionDays 都是 max 3650)。 +const MAX_RECLAIM_WINDOW_DAYS = 3650; + /** Merge persisted external-api kv over the defaults for client display. */ function fullExternalConfig(kv = {}) { return { @@ -93,7 +99,10 @@ function parseBody(text) { } } -export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) { +// maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、 +// 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable, +// 而不是把「没接线」伪装成崩溃(#386 第 4 条)。 +export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) { const disposers = []; // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要 @@ -691,6 +700,124 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- maintenance/reclaim(#386 第 4 条:面板有了回收入口)------------------ + // 与 standalone 数据面的 POST /maintenance/reclaim 是同一能力的两张皮,口径必须 + // 逐字一致(错误码字符串、默认值、报告形状);改这里时那边一起改。 + // + // 为什么 GET 也能跑:dry-run 是只读的(src/maintenance.js 里 dry-run 与执行同一条 + // 代码路径,只是不落 UPDATE/VACUUM),面板 mount 时拿它渲染「现在能回收多少」。 + // + // 为什么两法都要 requireAuth:这是不可逆内容丢弃的入口,不能有例外。 + register({ + kind: "exact", + path: "/api/dsh-mneme/maintenance/reclaim", + handler(req, res) { + try { + if (req.method === "GET") { + if (!requireAuth(req, res, apiToken)) return; + if (!maintenance) { + sendJson(res, 503, { error: "maintenance-unavailable" }); + return; + } + // query 是字符串域:只收十进制整数字面量,缺省才落默认窗口。**不能用 + // Number()**——它把 ""/[]/" " 都化成 0,而 0 在这里是「清掉全部快照」, + // 最坏解释绝不能从一个畸形输入静默拿到(POST 侧收 JSON 数字,口径不同)。 + const raw = new URL(req.url, "http://localhost").searchParams.get("olderThanDays"); + let olderThanDays; + if (raw === null) { + olderThanDays = undefined; + } else if (/^\d+$/.test(raw)) { + olderThanDays = Number(raw); + // 十进制字面量不保证是个能算日期的数:400 位数字会化成 Infinity。上界与 + // POST 侧同一条(见 MAX_RECLAIM_WINDOW_DAYS 的说明)。 + if (!Number.isFinite(olderThanDays) || olderThanDays > MAX_RECLAIM_WINDOW_DAYS) { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + } else { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + try { + sendJson(res, 200, maintenance.reclaim({ olderThanDays, dryRun: true })); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + return; + } + if (req.method !== "POST") { + sendJson(res, 404, { error: "not-found" }); + return; + } + if (!requireAuth(req, res, apiToken)) return; + return readBody(req).then((text) => { + // 不用 parseBody:它把坏 JSON 吞成 {},而这里 invalid-json 与 invalid-body + // 是两个不同的错(前者是调用方发坏了,后者是发成了数组/null)。 + let body; + try { + body = JSON.parse(text || "{}"); + } catch { + sendJson(res, 400, { error: "invalid-json" }); + return; + } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + sendJson(res, 400, { error: "invalid-body" }); + return; + } + if (!maintenance) { + sendJson(res, 503, { error: "maintenance-unavailable" }); + return; + } + const dryRun = body.dryRun !== false; + const olderThanDays = body.olderThanDays === undefined || body.olderThanDays === null + ? undefined + : body.olderThanDays; + if (olderThanDays !== undefined + && (!Number.isInteger(olderThanDays) || olderThanDays < 0 || olderThanDays > MAX_RECLAIM_WINDOW_DAYS)) { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + if (body.vacuum !== undefined && typeof body.vacuum !== "boolean") { + sendJson(res, 400, { error: "invalid-vacuum" }); + return; + } + if (!dryRun && body.confirm !== true) { + // 拒执行,但把 dry-run 报告一并回给调用方:确认的依据就是这份数字。报告 + // 必须描述**将要执行的那个动作**,所以 vacuum 原样带上——否则调用方看到 + // 的是「不 VACUUM」的数字,却带着 vacuum 去执行。 + // + // 这份报告是在**回调里**算的,外层 try 已经退栈了:它抛出去就是一个没人接的 + // rejection,客户端拿到的不是 500 而是「一个字节都没回」。所以本地兜住,与 + // 执行路径给出同一个 500 internal。 + let report; + try { + report = maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun: true }); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + return; + } + sendJson(res, 400, { + error: "confirm-required", + hint: "先看 dry-run 报告,确认后带 {\"dryRun\":false,\"confirm\":true} 再发", + report + }); + return; + } + try { + sendJson(res, 200, maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun })); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + }); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- rules --- register({ kind: "exact", @@ -1518,7 +1645,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return { - routes: 33, + routes: 34, dispose: () => { for (const dispose of disposers) dispose(); } diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index a788673..97803d6 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -746,6 +746,38 @@ window.__ModuleLoader__.load({ "memory.status.mirror.foot": "记忆条目在磁盘上的 Markdown 副本同步状态", "memory.status.sec.overview": "库内一览", "memory.status.sec.engine": "后台运转", + "memory.status.sec.maintenance": "维护", + "memory.status.maint": "存储回收", + "memory.status.maint.freelist": "空闲页可立刻回收", + "memory.status.maint.plannedInputs": "待清理输入快照", + "memory.status.maint.plannedEmbeddings": "待清理归档向量", + "memory.status.maint.window": "保留窗口", + "memory.status.maint.windowUnit": "天", + "memory.status.maint.windowLabel": "保留窗口(天)", + "memory.status.maint.reestimate": "重新估算", + "memory.status.maint.reestimating": "估算中…", + "memory.status.maint.stale": "窗口已改动:先点「重新估算」再执行,否则执行的是上一次估算的数字。", + "memory.status.maint.vacuum": "同时 VACUUM", + "memory.status.maint.vacuumHint": "VACUUM 会短暂独占数据库,期间其它读写要等待;不做也不影响清理结果。", + "memory.status.maint.execute": "执行回收", + "memory.status.maint.confirm": "确认执行(不可逆)", + "memory.status.maint.running": "执行中…", + "memory.status.maint.cancel": "取消", + "memory.status.maint.confirmHint": "将清理 {inputs} 行输入快照与 {embeddings} 行归档向量,且不可逆。", + "memory.status.maint.badWindow": "保留窗口要填不小于 0 的整数", + "memory.status.maint.failed": "回收失败", + "memory.status.maint.done": "回收已完成", + "memory.status.maint.size": "体积(前 → 后)", + "memory.status.maint.reclaimed": "实际释放", + "memory.status.maint.clearedInputs": "已清理输入快照", + "memory.status.maint.clearedEmbeddings": "已清理归档向量", + "memory.status.maint.receipt": "审计回执", + "memory.status.maint.receiptNone": "未记录(审计未启用或回执写入失败)", + "memory.status.maint.duration": "耗时", + "memory.status.maint.vacuumRan": "VACUUM 已执行", + "memory.status.maint.vacuumOff": "未 VACUUM", + "memory.status.maint.vacuumFailed": "VACUUM 失败", + "memory.status.maint.vacuumFailedHint": "清理已完成且不可逆,但文件暂时不会变小;稍后重跑即可(清理是幂等的)。", "memory.status.llm": "LLM 消耗", "memory.status.llmCalls": "近 7 天 · {n} 次调用", "memory.status.error": "加载失败" @@ -1235,6 +1267,38 @@ window.__ModuleLoader__.load({ "memory.status.mirror.foot": "Sync state of the on-disk Markdown mirror of memory entries", "memory.status.sec.overview": "Library", "memory.status.sec.engine": "Background activity", + "memory.status.sec.maintenance": "Maintenance", + "memory.status.maint": "Storage reclaim", + "memory.status.maint.freelist": "Free pages reclaimable now", + "memory.status.maint.plannedInputs": "Input snapshots to clear", + "memory.status.maint.plannedEmbeddings": "Archived embeddings to clear", + "memory.status.maint.window": "Retention window", + "memory.status.maint.windowUnit": "days", + "memory.status.maint.windowLabel": "Retention window (days)", + "memory.status.maint.reestimate": "Re-estimate", + "memory.status.maint.reestimating": "Estimating…", + "memory.status.maint.stale": "Window changed - re-estimate before running, or the run would use the previous estimate's numbers.", + "memory.status.maint.vacuum": "VACUUM as well", + "memory.status.maint.vacuumHint": "VACUUM briefly takes an exclusive lock on the database; other reads and writes wait. Skipping it does not change what gets cleared.", + "memory.status.maint.execute": "Run reclaim", + "memory.status.maint.confirm": "Confirm (irreversible)", + "memory.status.maint.running": "Running…", + "memory.status.maint.cancel": "Cancel", + "memory.status.maint.confirmHint": "This clears {inputs} input-snapshot rows and {embeddings} archived-embedding rows. It cannot be undone.", + "memory.status.maint.badWindow": "The retention window must be an integer of at least 0", + "memory.status.maint.failed": "Reclaim failed", + "memory.status.maint.done": "Reclaim finished", + "memory.status.maint.size": "Size (before → after)", + "memory.status.maint.reclaimed": "Actually freed", + "memory.status.maint.clearedInputs": "Input snapshots cleared", + "memory.status.maint.clearedEmbeddings": "Archived embeddings cleared", + "memory.status.maint.receipt": "Audit receipt", + "memory.status.maint.receiptNone": "Not recorded (audit disabled or receipt write failed)", + "memory.status.maint.duration": "Duration", + "memory.status.maint.vacuumRan": "VACUUM ran", + "memory.status.maint.vacuumOff": "Not VACUUMed", + "memory.status.maint.vacuumFailed": "VACUUM failed", + "memory.status.maint.vacuumFailedHint": "The reclaim already completed and cannot be undone, but the file will not shrink yet; run it again later (reclaim is idempotent).", "memory.status.llm": "LLM Usage", "memory.status.llmCalls": "Last 7 days · {n} calls", "memory.status.error": "Failed to load" @@ -1706,7 +1770,17 @@ window.__ModuleLoader__.load({ ".mneme-feedtext{display:flex;flex-direction:column;gap:1px;min-width:0}", ".mneme-feedname{font-size:13px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-primary)}", ".mneme-feedsub{font-size:12px;line-height:17px;color:var(--mneme-fg-dim)}", - ".mneme-set-foot{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px;color:var(--mneme-fg-dim)}" + ".mneme-set-foot{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px;color:var(--mneme-fg-dim)}", + // --- 存储回收卡(#386 第 4 条):控制行 + 两步确认的警告块 --- + ".mneme-maintrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}", + ".mneme-maintlbl{flex:none;font-size:12px;line-height:17px;color:var(--mneme-fg-dim)}", + ".mneme-maintrow .mneme-set-input{flex:none;width:96px;margin-bottom:0}", + ".mneme-maintcheck{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary)}", + ".mneme-maintwarn{margin-top:12px;padding:10px 12px;border-radius:10px;border:1px solid color-mix(in srgb,var(--mneme-danger) 45%,transparent);background:color-mix(in srgb,var(--mneme-danger) 8%,transparent)}", + ".mneme-maintwarnhead{font-size:13px;line-height:19px;font-weight:600;color:var(--mneme-danger)}", + ".mneme-maintresult{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}", + ".mneme-maintresulthead{font-size:13px;line-height:19px;font-weight:600;color:var(--mneme-ok)}", + ".mneme-mainterr{margin-top:10px;font-size:12px;line-height:17px;color:var(--mneme-danger)}" ].join("\n"); if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) { const tag = document.createElement("style"); @@ -3618,6 +3692,250 @@ window.__ModuleLoader__.load({ ); } + // 人读体积:回收报告里全是字节数(六到七位),不折成 KiB/MiB 就看不出「到底有没有 + // 变小」——而那正是这张卡要回答的问题。 + function formatBytes(n) { + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return "—"; + if (v < 1024) return `${v} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let x = v / 1024; + let i = 0; + while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; } + return `${x.toFixed(x >= 100 ? 0 : x >= 10 ? 1 : 2)} ${units[i]}`; + } + + // 存储回收卡(issue #386 第 4 条,状态页「维护」区)。`dsh-mneme reclaim` 此前只有 + // CLI 与 standalone 两张脸,面板侧一个字都没有——「不可逆的内容丢弃」这种能力要么 + // 能被发现,要么等于不存在。 + // + // 三条不变量,别在「顺手简化」时弄丢: + // ① 执行是两步。第一次点只进确认态,把这次要动的数字摆到眼前;第二次点才真的发 + // POST。不用 window.confirm——宿主 webview 里它不一定可用,而且它挡不住「到底 + // 要清多少行」这个问题。 + // ② VACUUM 失败 ≠ 成功。清理的两个 UPDATE 各自提交且不可逆,VACUUM 只负责把文件 + // 缩回去,失败最常见的原因是锁被别的进程占着。所以失败时必须同时说清四件事: + // 清理已完成、不可逆、文件暂时不会变小、下一步重跑即可(清理是幂等的)。绝不 + // 能渲染成一次干净的「成功」。 + // ③ 保留窗口只在点「重新估算」时发一次请求。键入即发 = 想输 30 却先敲出 3 的那一刻 + // 就换了估算,数字会跳。而且估算一旦与输入框不一致,确认态里摆出来的就是另一个 + // 动作的数字——所以那种状态下执行钮直接禁用(见下面的 stale)。 + // + // 报告字段由 src/maintenance.js 的 reclaim() 决定,卡片只读不改。 + function MaintenanceCard({ t }) { + // 与 src/maintenance.js 的 DEFAULT_INPUT_RETENTION_DAYS 同值:这里只用来初始化 + // 输入框与首屏那次 GET,真正的默认值仍由服务端兜底。 + const DEFAULT_WINDOW_DAYS = 7; + // 与后端 MAX_RECLAIM_WINDOW_DAYS 同值。超界窗口服务端会回 400,但本地先拦一道: + // 否则用户点「重新估算」只会看到一句服务端错误,还以为是网络问题。 + const MAX_WINDOW_DAYS = 3650; + // 面板走 in-process 路由,与 CLI/standalone 底下是同一条 reclaim 实现。 + const RECLAIM_PATH = "/api/dsh-mneme/maintenance/reclaim"; + const [days, setDays] = useState(String(DEFAULT_WINDOW_DAYS)); + const [vacuum, setVacuum] = useState(false); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [estimating, setEstimating] = useState(false); + const [error, setError] = useState(""); + // 确认态:true = 第一次点过了,此刻按钮换成实心红确认钮。 + const [armed, setArmed] = useState(false); + const [running, setRunning] = useState(false); + // 成功与失败分开存:合成一个对象容易在「先失败后成功」时把上一次的残留显示出来。 + const [result, setResult] = useState(null); + const [resultError, setResultError] = useState(""); + + // 挂载即预估:打开状态页就该看见「现在能收多少」,而不是先点一下才有数字。 + useEffect(() => { + let cancelled = false; + apiFetch(`${RECLAIM_PATH}?olderThanDays=${DEFAULT_WINDOW_DAYS}`) + .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); }) + .then((j) => { if (!cancelled) { setReport(j); setLoading(false); } }) + .catch(() => { if (!cancelled) { setError(t("memory.status.maint.failed")); setLoading(false); } }); + return () => { cancelled = true; }; + }, []); + + // 只由「重新估算」按钮调用,不在 onChange 里调用(见上面 ③)。 + const estimate = () => { + if (!/^\d+$/.test(days)) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; } + // 上界也在这里拦:`1e20` 与 400 位数字都能过 /^\d+$/(后者化成 Infinity), + // 后端会回 400,但那样用户看到的是一句「回收失败」,不如直接说窗口不合法。 + if (Number(days) > MAX_WINDOW_DAYS) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; } + setEstimating(true); + setError(""); + // 窗口一变,上一轮的确认态连同它显示的数字一起作废;上一次执行的回执也别再跟 + // 新的估算混在一起。 + setArmed(false); + setResult(null); + setResultError(""); + apiFetch(`${RECLAIM_PATH}?olderThanDays=${days}`) + .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); }) + .then((j) => { setReport(j); setEstimating(false); }) + .catch(() => { setError(t("memory.status.maint.failed")); setEstimating(false); }); + }; + + // 第二次点击(确认态里)才走这里。dryRun:false 与 confirm:true 是后端那条不可逆 + // 动作的确认门:缺 confirm 会回 400 confirm-required 并附一份 dry-run 报告。两个 + // 字面量逐字保留——改名或省掉一个都等于把门拆了。 + const execute = () => { + setRunning(true); + setResultError(""); + apiFetch(RECLAIM_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dryRun: false, confirm: true, olderThanDays: Number(days), vacuum }) + }) + .then((res) => res.json().catch(() => null).then((body) => ({ ok: res.ok, status: res.status, body }))) + .then((r) => { + // 非 2xx 一律落到这条分支并带着服务端的 error 串返回:成功态只在守卫另一侧。 + if (!r.ok) { + setResultError(`${t("memory.status.maint.failed")} · ${(r.body && r.body.error) || `HTTP ${r.status}`}`); + setRunning(false); + return; + } + setResult(r.body); + setArmed(false); + setRunning(false); + }) + .catch(() => { setResultError(t("memory.status.maint.failed")); setRunning(false); }); + }; + + const size = (report && report.size) || {}; + const planned = (report && report.planned) || {}; + const rv = (result && result.vacuum) || {}; + const rsz = (result && result.size) || {}; + const rcl = (result && result.cleared) || {}; + // 估算数字与输入框不一致(刚改过窗口还没重新估算)时禁止执行:否则确认态里摆出来 + // 的是另一个窗口的数字,而对不可逆操作来说这是最坏的一种错位。 + const stale = !report || String(report.olderThanDays) !== days; + // vacuum.error 是 string|null;非 null 就是「清理成功但文件没缩」这种半成功态。 + const vacuumError = rv.error == null ? null : String(rv.error); + const freed = Number(rsz.before_bytes ?? 0) - Number(rsz.after_bytes ?? 0); + + return h("div", { className: "mneme-statuscard mneme-statuscard--wide" }, + h("h3", { className: "mneme-xcolhead" }, t("memory.status.maint")), + loading + ? h("div", { className: "mneme-statusnum" }, "…") + : h("div", { className: "mneme-statusnum" }, formatBytes(size.before_bytes)), + report ? h("div", { className: "mneme-statrows" }, + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.freelist")), + h("span", { className: "mneme-statrowval" }, `${formatBytes(size.freelist_bytes)} · ${size.freelist_pages ?? 0}`)), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.plannedInputs")), + h("span", { className: "mneme-statrowval" }, String(planned.dream_run_inputs ?? 0))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.plannedEmbeddings")), + h("span", { className: "mneme-statrowval" }, String(planned.archived_embeddings ?? 0))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.window")), + h("span", { className: "mneme-statrowval" }, `${report.olderThanDays} ${t("memory.status.maint.windowUnit")}`)) + ) : null, + // 控制区:窗口只在点按钮时重新估算;VACUUM 默认关,并明说它会短暂独占数据库。 + h("div", { className: "mneme-maintrow" }, + h("span", { className: "mneme-maintlbl" }, t("memory.status.maint.windowLabel")), + h("input", { + className: "mneme-set-input", + inputMode: "numeric", + value: days, + disabled: armed || running, + onChange: (e) => setDays(e.target.value) + }), + h("button", { + type: "button", + className: "mneme-btn", + disabled: armed || running || estimating, + onClick: estimate + }, estimating ? t("memory.status.maint.reestimating") : t("memory.status.maint.reestimate")) + ), + h("label", { className: "mneme-maintcheck" }, + h("input", { + type: "checkbox", + checked: vacuum, + disabled: armed || running, + onChange: (e) => setVacuum(e.target.checked) + }), + h("span", null, t("memory.status.maint.vacuum")) + ), + h("div", { className: "mneme-statuscap" }, t("memory.status.maint.vacuumHint")), + stale && !loading && !estimating + ? h("div", { className: "mneme-statuscap" }, t("memory.status.maint.stale")) + : null, + // 两步确认。未武装时只有「执行回收」(点了只进确认态,不发请求);武装后换成实心 + // 红确认钮,并把窗口/VACUUM 在确认态里锁住——保证屏幕上的数字就是即将发出的动作。 + armed + ? h(react.Fragment, null, + h("div", { className: "mneme-maintwarn" }, + h("div", { className: "mneme-maintwarnhead" }, + t("memory.status.maint.confirmHint") + .replace("{inputs}", String(planned.dream_run_inputs ?? 0)) + .replace("{embeddings}", String(planned.archived_embeddings ?? 0))), + h("div", { className: "mneme-statuscap" }, + `${t("memory.status.maint.window")} ${report.olderThanDays} ${t("memory.status.maint.windowUnit")} · ` + + (vacuum ? t("memory.status.maint.vacuum") : t("memory.status.maint.vacuumOff"))) + ), + h("div", { className: "mneme-maintrow" }, + h("button", { + type: "button", + className: "mneme-btn mneme-btndangerconfirm", + disabled: running, + onClick: execute + }, running ? t("memory.status.maint.running") : t("memory.status.maint.confirm")), + h("button", { + type: "button", + className: "mneme-btn", + disabled: running, + onClick: () => setArmed(false) + }, t("memory.status.maint.cancel")) + ) + ) + : h("div", { className: "mneme-maintrow" }, + h("button", { + type: "button", + className: "mneme-btn mneme-btndanger", + disabled: running || loading || estimating || stale, + onClick: () => setArmed(true) + }, t("memory.status.maint.execute")) + ), + result ? h("div", { className: "mneme-maintresult" }, + h("div", { className: "mneme-maintresulthead" }, + vacuumError ? t("memory.status.maint.vacuumFailed") : t("memory.status.maint.done")), + h("div", { className: "mneme-statrows" }, + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.clearedInputs")), + h("span", { className: "mneme-statrowval" }, String(rcl.dream_run_inputs ?? 0))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.clearedEmbeddings")), + h("span", { className: "mneme-statrowval" }, String(rcl.archived_embeddings ?? 0))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.size")), + h("span", { className: "mneme-statrowval" }, `${formatBytes(rsz.before_bytes)} → ${formatBytes(rsz.after_bytes)}`)), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.reclaimed")), + h("span", { className: "mneme-statrowval" }, formatBytes(freed))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.receipt")), + h("span", { className: "mneme-statrowval" }, + result.receipt == null ? t("memory.status.maint.receiptNone") : String(result.receipt))), + h("div", { className: "mneme-statrow" }, + h("span", null, t("memory.status.maint.duration")), + h("span", { className: "mneme-statrowval" }, `${Number(result.duration_ms ?? 0)} ms`)) + ), + // VACUUM 失败 ≠ 成功:清理已经落库且不可逆,失败的只是「把文件缩回去」这一步。 + // 这里必须把「已完成 + 不可逆 + 暂时不会变小」和下一步(重跑即可,清理是幂等 + // 的)一起说清,否则用户只看到「没变小」会以为白清了。 + vacuumError + ? h("div", { className: "mneme-maintwarn" }, + h("div", { className: "mneme-maintwarnhead" }, + `${t("memory.status.maint.vacuumFailed")} · ${vacuumError}`), + h("div", { className: "mneme-statuscap" }, t("memory.status.maint.vacuumFailedHint"))) + : h("div", { className: "mneme-statuscap" }, + rv.ran ? t("memory.status.maint.vacuumRan") : t("memory.status.maint.vacuumOff")) + ) : null, + resultError ? h("div", { className: "mneme-mainterr" }, resultError) : null, + error ? h("div", { className: "mneme-mainterr" }, error) : null + ); + } + // Markdown 镜像健康卡(issue #386 第 3 条)。后端 /health 对镜像读失败是 // fail-closed(dirty=null → unknown);面板侧同样 fail-closed:fetch 失败 // 或任何非 ok/degraded 的 status 一律渲染成「未知」,绝不显示「正常」—— @@ -4281,6 +4599,8 @@ window.__ModuleLoader__.load({ h(InjectStatusCard, { t }), h(InjectPreviewCard, { t }) ), + h("div", { className: "mneme-statushead" }, t("memory.status.sec.maintenance")), + h("div", { className: "mneme-statusgrid" }, h(MaintenanceCard, { t })), h("div", { ref: queueRef }, h(ConflictsQueue, { t })), h(WorkbenchSection, { t, onBrowse }) ); diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index 8b6686d..9a7ef3b 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -483,6 +483,15 @@ export const apply = (ctx, config) => { const summarizer = createSummarizer(ctx, service, cfg); disposers.push(summarizer.dispose); + // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器—— + // 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。 + // 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的 + // 那条路走不通。 + // 位置在 webServer 块之前:面板的 in-process API(createApi)与 standalone 数据面 + // 都要用它,而 webServer 是可选的——headless 宿主不注册面板路由,构造点若留在块后 + // 就永远晚于 createApi(#386 第 4 条)。构造期无副作用(只返回 { reclaim }),前移安全。 + const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger }); + // webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的 // 属性会抛 "cannot get property without inject"(不会返回 undefined),所以 // 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject @@ -495,16 +504,10 @@ export const apply = (ctx, config) => { add: () => { throw new Error("commands unavailable"); }, remove: () => false, list: () => [] - }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg); + }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance); disposers.push(api.dispose); } - // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器—— - // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、 - // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主 - // 抢锁的那条路走不通)。 - const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger }); - // Standalone external API (v0.7.12): plain node:http server for ecosystem // integrations outside the DSH host. Persisted external_api settings win // over the bundle config (enabled/port); the Bearer token lives in the same diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index b149ae5..a96434f 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -33,6 +33,12 @@ function sendAttachment(res, status, contentType, filename, body) { // defaults in config.js (externalApiPort / externalApiHost). const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" }; +// 回收窗口(天)的上界。maintenance.reclaim 拿这个数算 `now() - days * 86400000` 再 +// toISOString():日期一旦超出 Date 的有效范围就抛 RangeError。整数校验挡不住量级—— +// 1e20 也是「整数」,JSON 里还能写成 400 位数字。所以显式钉一个上界,取值与既有保留 +// 窗口配置一致(config.js 的 llmAudit.retentionDays / recallRetentionDays 都是 max 3650)。 +const MAX_RECLAIM_WINDOW_DAYS = 3650; + /** Merge persisted external-api kv over the defaults for client display. */ function fullExternalConfig(kv = {}) { return { @@ -93,7 +99,10 @@ function parseBody(text) { } } -export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) { +// maintenance 是第 9 个位置参数,默认 null:既有调用点(测试里的 14 处)全是位置传参、 +// 不传它,必须继续工作——不传时 /maintenance/reclaim 回 503 maintenance-unavailable, +// 而不是把「没接线」伪装成崩溃(#386 第 4 条)。 +export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null, maintenance = null) { const disposers = []; // #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要 @@ -691,6 +700,124 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- maintenance/reclaim(#386 第 4 条:面板有了回收入口)------------------ + // 与 standalone 数据面的 POST /maintenance/reclaim 是同一能力的两张皮,口径必须 + // 逐字一致(错误码字符串、默认值、报告形状);改这里时那边一起改。 + // + // 为什么 GET 也能跑:dry-run 是只读的(src/maintenance.js 里 dry-run 与执行同一条 + // 代码路径,只是不落 UPDATE/VACUUM),面板 mount 时拿它渲染「现在能回收多少」。 + // + // 为什么两法都要 requireAuth:这是不可逆内容丢弃的入口,不能有例外。 + register({ + kind: "exact", + path: "/api/dsh-mneme/maintenance/reclaim", + handler(req, res) { + try { + if (req.method === "GET") { + if (!requireAuth(req, res, apiToken)) return; + if (!maintenance) { + sendJson(res, 503, { error: "maintenance-unavailable" }); + return; + } + // query 是字符串域:只收十进制整数字面量,缺省才落默认窗口。**不能用 + // Number()**——它把 ""/[]/" " 都化成 0,而 0 在这里是「清掉全部快照」, + // 最坏解释绝不能从一个畸形输入静默拿到(POST 侧收 JSON 数字,口径不同)。 + const raw = new URL(req.url, "http://localhost").searchParams.get("olderThanDays"); + let olderThanDays; + if (raw === null) { + olderThanDays = undefined; + } else if (/^\d+$/.test(raw)) { + olderThanDays = Number(raw); + // 十进制字面量不保证是个能算日期的数:400 位数字会化成 Infinity。上界与 + // POST 侧同一条(见 MAX_RECLAIM_WINDOW_DAYS 的说明)。 + if (!Number.isFinite(olderThanDays) || olderThanDays > MAX_RECLAIM_WINDOW_DAYS) { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + } else { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + try { + sendJson(res, 200, maintenance.reclaim({ olderThanDays, dryRun: true })); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + return; + } + if (req.method !== "POST") { + sendJson(res, 404, { error: "not-found" }); + return; + } + if (!requireAuth(req, res, apiToken)) return; + return readBody(req).then((text) => { + // 不用 parseBody:它把坏 JSON 吞成 {},而这里 invalid-json 与 invalid-body + // 是两个不同的错(前者是调用方发坏了,后者是发成了数组/null)。 + let body; + try { + body = JSON.parse(text || "{}"); + } catch { + sendJson(res, 400, { error: "invalid-json" }); + return; + } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + sendJson(res, 400, { error: "invalid-body" }); + return; + } + if (!maintenance) { + sendJson(res, 503, { error: "maintenance-unavailable" }); + return; + } + const dryRun = body.dryRun !== false; + const olderThanDays = body.olderThanDays === undefined || body.olderThanDays === null + ? undefined + : body.olderThanDays; + if (olderThanDays !== undefined + && (!Number.isInteger(olderThanDays) || olderThanDays < 0 || olderThanDays > MAX_RECLAIM_WINDOW_DAYS)) { + sendJson(res, 400, { error: "invalid-older-than-days" }); + return; + } + if (body.vacuum !== undefined && typeof body.vacuum !== "boolean") { + sendJson(res, 400, { error: "invalid-vacuum" }); + return; + } + if (!dryRun && body.confirm !== true) { + // 拒执行,但把 dry-run 报告一并回给调用方:确认的依据就是这份数字。报告 + // 必须描述**将要执行的那个动作**,所以 vacuum 原样带上——否则调用方看到 + // 的是「不 VACUUM」的数字,却带着 vacuum 去执行。 + // + // 这份报告是在**回调里**算的,外层 try 已经退栈了:它抛出去就是一个没人接的 + // rejection,客户端拿到的不是 500 而是「一个字节都没回」。所以本地兜住,与 + // 执行路径给出同一个 500 internal。 + let report; + try { + report = maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun: true }); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + return; + } + sendJson(res, 400, { + error: "confirm-required", + hint: "先看 dry-run 报告,确认后带 {\"dryRun\":false,\"confirm\":true} 再发", + report + }); + return; + } + try { + sendJson(res, 200, maintenance.reclaim({ olderThanDays, vacuum: body.vacuum === true, dryRun })); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] storage reclaim failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + }); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- rules --- register({ kind: "exact", @@ -1518,7 +1645,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return { - routes: 33, + routes: 34, dispose: () => { for (const dispose of disposers) dispose(); } diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index 8b6686d..9a7ef3b 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -483,6 +483,15 @@ export const apply = (ctx, config) => { const summarizer = createSummarizer(ctx, service, cfg); disposers.push(summarizer.dispose); + // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器—— + // 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。 + // 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的 + // 那条路走不通。 + // 位置在 webServer 块之前:面板的 in-process API(createApi)与 standalone 数据面 + // 都要用它,而 webServer 是可选的——headless 宿主不注册面板路由,构造点若留在块后 + // 就永远晚于 createApi(#386 第 4 条)。构造期无副作用(只返回 { reclaim }),前移安全。 + const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger }); + // webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的 // 属性会抛 "cannot get property without inject"(不会返回 undefined),所以 // 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject @@ -495,16 +504,10 @@ export const apply = (ctx, config) => { add: () => { throw new Error("commands unavailable"); }, remove: () => false, list: () => [] - }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg); + }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance); disposers.push(api.dispose); } - // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器—— - // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、 - // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主 - // 抢锁的那条路走不通)。 - const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger }); - // Standalone external API (v0.7.12): plain node:http server for ecosystem // integrations outside the DSH host. Persisted external_api settings win // over the bundle config (enabled/port); the Bearer token lives in the same diff --git a/dsh-mneme/test/api-routes-gaps.test.js b/dsh-mneme/test/api-routes-gaps.test.js index 527bd7e..020d808 100644 --- a/dsh-mneme/test/api-routes-gaps.test.js +++ b/dsh-mneme/test/api-routes-gaps.test.js @@ -1,10 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createStore } from "../src/store.js"; import { createService } from "../src/service.js"; import { createApi } from "../src/api.js"; import { createSettings } from "../src/settings.js"; +import { createMaintenance } from "../src/maintenance.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); // 覆盖 PR#73 删测后遗留的三个存活 API 空白(v0.7.16 回归补测): // /api/dsh-mneme/delete 面板删记忆(POST + auth + 存在性 404) @@ -371,3 +378,508 @@ test("create returns 500 (not a leaked throw) when the store rejects the write", } }); +// --------------------------------------------------------------------------- +// /api/dsh-mneme/maintenance/reclaim(#386 第 4 条:存储回收的面板入口) +// --------------------------------------------------------------------------- +// 这条路由与 standalone 数据面的 POST /maintenance/reclaim 是同一个能力的两张皮, +// 底下是同一份 src/maintenance.js。锁盯的是两类回归: +// a) 口径漂移——状态码、错误码字符串、默认值必须与 src/api-standalone.js:485-535 +// 逐字一致,否则同一个动作在两个入口上给出不同的答复,客户端只能各写一套; +// b) 最坏解释——dry-run 路径必须一个字节都不改;畸形输入绝不能被静默读成 +// olderThanDays=0(0 在这里的含义是「清掉全部快照」)。 +const RECLAIM_PATH = "/api/dsh-mneme/maintenance/reclaim"; + +/** + * 回收测试台。必须落在**文件库**上:`:memory:` 库的 storageStats().fileBytes 是 + * null(store.js:2134),而报告的口径就是「按 VACUUM 前后体积量」——用内存库测, + * 体积那几格永远测不到数字。 + */ +function reclaimSetup(apiToken = "secret-token", { logger = null } = {}) { + const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-reclaim-api-")); + const store = createStore(join(dir, "memory.db")); + const service = createService({ store, mirror: null, config: {} }); + const settings = createSettings(store.db); + const commands = { add: () => {}, remove: () => {}, list: () => [] }; + const routes = []; + const ctx = { logger, webServer: { register(route) { routes.push(route); return () => {}; } } }; + const maintenance = createMaintenance({ store, config: {}, logger }); + createApi(ctx, service, settings, commands, null, undefined, apiToken, null, maintenance); + return { + store, service, routes, maintenance, + // 闭包拿 store,不用 this:裸函数调用下 this 是 undefined,异常被吞掉就会变成 + // 「库没关就删目录」的 EPERM(清理失败还说不清原因)。 + close() { + try { store.close(); } catch { /* already closed */ } + rmSync(dir, { recursive: true, force: true }); + } + }; +} + +/** 一条窗口外(30 天前)的 run 输入快照 + 一行带向量的归档行:让 planned/cleared + * 都有非零数字,否则「cleared 非零」这类锁在恒零的报告上也能通过。 */ +function seedReclaim(store) { + store.saveDreamRun({ + id: "api-old-run", + created_at: new Date(Date.now() - 30 * 86400000).toISOString(), + status: "ok", + snapshot_hash: "h-api-old", + input_count: 1, + input: JSON.stringify([{ id: "m1", content: "x".repeat(600) }]), + decisions: JSON.stringify([{ action: "keep", ids: ["m1"] }]), + receipt: "dsh-mneme:run:api-old-run:ok:h-api-old" + }); + const archived = store.save({ type: "project", title: "归档行", content: "归档内容" }); + store.setEmbedding(archived.id, [0.5, 0.25, 0.125]); + store.setArchived(archived.id, true); + return archived; +} + +/** + * 把三个「真会改数据」的原语换成计数器 + 透传。dry-run 的锁要说的都是同一句话: + * 「这条路径上一行都没改」。只看返回数字不够——返回的 0 也可能是「改了但没报」。 + */ +function spyWrites(store) { + const calls = { clearDreamRunInputs: 0, clearArchivedEmbeddings: 0, vacuum: 0 }; + const orig = { + clearDreamRunInputs: store.clearDreamRunInputs, + clearArchivedEmbeddings: store.clearArchivedEmbeddings, + vacuum: store.vacuum + }; + store.clearDreamRunInputs = (...args) => { calls.clearDreamRunInputs += 1; return orig.clearDreamRunInputs(...args); }; + store.clearArchivedEmbeddings = (...args) => { calls.clearArchivedEmbeddings += 1; return orig.clearArchivedEmbeddings(...args); }; + store.vacuum = (...args) => { calls.vacuum += 1; return orig.vacuum(...args); }; + return { + calls, + restore() { + store.clearDreamRunInputs = orig.clearDreamRunInputs; + store.clearArchivedEmbeddings = orig.clearArchivedEmbeddings; + store.vacuum = orig.vacuum; + } + }; +} + +/** 原始 body 版请求:畸形 JSON 锁要发的是**不能**被 JSON.stringify 出来的文本。 */ +function reqRaw(path, method, text, headers = {}) { + const r = new EventEmitter(); + r.url = path; + r.method = method; + r.headers = headers; + process.nextTick(() => { + r.emit("data", Buffer.from(text)); + r.emit("end"); + }); + return r; +} + +async function callReclaim(route, { + path = RECLAIM_PATH, method = "GET", body = null, raw = false, token = "secret-token" +} = {}) { + const sendBody = body !== null || raw; + const r = sendBody + ? reqRaw(path, method, raw ? body : JSON.stringify(body)) + : req(path, method); + if (token !== null) r.headers = { authorization: `Bearer ${token}` }; + const res = new FakeRes(); + await route.handler(r, res); + return { status: res.statusCode, body: res.body ? JSON.parse(res.body) : null }; +} + +/** 从 `(` 起做括号配平扫描,返回整段调用的文本。路由调用里嵌着对象字面量与箭头 + * 函数,用 indexOf(");") 会先停在内层 `throw new Error(...)` 上。 */ +function callText(src, start) { + const open = src.indexOf("(", start); + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === "(") depth += 1; + else if (src[i] === ")") { + depth -= 1; + if (depth === 0) return src.slice(start, i + 1); + } + } + return src.slice(start); +} + +test("reclaim is auth-gated on both methods and GET returns a dry-run", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + assert.ok(route, "route must be registered on the panel API"); + + // 不可逆内容丢弃的入口,两个方法都不能有例外。 + let out = await callReclaim(route, { token: null }); + assert.equal(out.status, 401); + assert.equal(out.body.error, "unauthorized"); + + out = await callReclaim(route, { method: "POST", body: {}, token: null }); + assert.equal(out.status, 401); + + out = await callReclaim(route); + assert.equal(out.status, 200); + assert.equal(out.body.dryRun, true, "GET 是只读预估,恒为 dry-run"); + assert.equal(out.body.olderThanDays, 7, "缺省用 DEFAULT_INPUT_RETENTION_DAYS"); + } finally { + bench.close(); + } +}); + +test("reclaim GET honours olderThanDays and rejects malformed windows", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + let out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=3` }); + assert.equal(out.status, 200); + assert.equal(out.body.olderThanDays, 3); + + // 只收十进制整数字面量。`Number()` 会把 ""/[]/" " 化成 0,而 0 在这里是 + // 「清掉全部快照」——最坏解释绝不能从一个畸形输入静默拿到。 + for (const bad of ["abc", "-1", "", " ", "3.5", "0x2", "1e3", "[]", "null", "3d"]) { + out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=${encodeURIComponent(bad)}` }); + assert.equal(out.status, 400, `olderThanDays=${JSON.stringify(bad)} 必须被拒`); + assert.equal(out.body.error, "invalid-older-than-days"); + } + + // 边界值 0 是**合法**的(含义就是清全部),不能被上面那条锁顺手误伤。 + out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=0` }); + assert.equal(out.status, 200); + assert.equal(out.body.olderThanDays, 0); + + // 量级也要挡:十进制字面量不保证是能算日期的数,400 位数字会化成 Infinity, + // 而 maintenance 会拿它算 `now() - days * 86400000` 再 toISOString() 抛 RangeError。 + for (const huge of ["1" + "0".repeat(400), "100000000000000000000", "3651"]) { + out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=${huge}` }); + assert.equal(out.status, 400, `olderThanDays=${huge} 必须被拒`); + assert.equal(out.body.error, "invalid-older-than-days"); + } + out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=3650` }); + assert.equal(out.status, 200, "上界本身是合法的"); + assert.equal(out.body.olderThanDays, 3650); + } finally { + bench.close(); + } +}); + +test("reclaim POST defaults to dry-run and writes nothing", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + const spy = spyWrites(bench.store); + try { + const out = await callReclaim(route, { method: "POST", body: {} }); + assert.equal(out.status, 200); + assert.equal(out.body.dryRun, true, "不显式要求就不执行"); + assert.equal(out.body.planned.dream_run_inputs, 1); + assert.equal(spy.calls.clearDreamRunInputs, 0); + assert.equal(spy.calls.clearArchivedEmbeddings, 0); + assert.equal(spy.calls.vacuum, 0); + assert.equal(bench.store.listLlmAudits().length, 0, "dry-run 不落回执"); + } finally { + spy.restore(); + } + } finally { + bench.close(); + } +}); + +test("reclaim POST without confirm refuses to execute but hands back the dry-run report", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + const spy = spyWrites(bench.store); + try { + const out = await callReclaim(route, { method: "POST", body: { dryRun: false, vacuum: true } }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "confirm-required"); + assert.match(out.body.hint, /dryRun/); + assert.ok(out.body.report, "确认的依据就是这份数字,必须一并回给调用方"); + // 报告描述的必须是**将要执行的那个动作**:这里带了 vacuum 去执行,看到的 + // 数字就得是「会 VACUUM」的那一份,否则调用方照着「不 VACUUM」的估算按下去。 + assert.equal(out.body.report.vacuum.requested, true); + assert.equal(out.body.report.dryRun, true); + assert.equal(spy.calls.clearDreamRunInputs, 0, "拒绝执行就是真的一行都没改"); + assert.equal(spy.calls.clearArchivedEmbeddings, 0); + assert.equal(spy.calls.vacuum, 0); + } finally { + spy.restore(); + } + } finally { + bench.close(); + } +}); + +test("reclaim POST with confirm executes and reports what it cleared", async () => { + const bench = reclaimSetup(); + try { + const archived = seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + const out = await callReclaim(route, { + method: "POST", + body: { dryRun: false, confirm: true, olderThanDays: 7, vacuum: false } + }); + assert.equal(out.status, 200); + assert.equal(out.body.dryRun, false); + assert.equal(out.body.olderThanDays, 7); + assert.equal(out.body.cleared.dream_run_inputs, 1, "窗口外的输入快照真的被清了"); + assert.equal(out.body.cleared.archived_embeddings, 1); + // 行数不变(这项回收只置列,不删行)——它「无损」的全部含义就在这里。 + assert.equal(bench.store.db.prepare("SELECT count(*) AS c FROM dream_runs").get().c, 1); + assert.equal(bench.store.db.prepare("SELECT input FROM dream_runs WHERE id = 'api-old-run'").get().input, null); + assert.equal(bench.store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(archived.id).embedding, null); + } finally { + bench.close(); + } +}); + +test("reclaim POST rejects a non-boolean vacuum", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + for (const bad of ["yes", 1, 0, {}, []]) { + const out = await callReclaim(route, { method: "POST", body: { vacuum: bad } }); + assert.equal(out.status, 400, `vacuum=${JSON.stringify(bad)} 必须被拒`); + assert.equal(out.body.error, "invalid-vacuum"); + } + // 显式 false 是合法的:面板的 checkbox 默认关,传的就是它。 + const ok = await callReclaim(route, { method: "POST", body: { vacuum: false } }); + assert.equal(ok.status, 200); + } finally { + bench.close(); + } +}); + +test("reclaim POST separates invalid-json from invalid-body", async () => { + const bench = reclaimSetup(); + try { + const route = handler(bench.routes, RECLAIM_PATH); + + // 发坏了(解析不了)与发成了非对象(null/数组)是两种不同的错,处置也不同: + // 前者查客户端序列化,后者查调用方把数组当 body 传了。 + let out = await callReclaim(route, { method: "POST", raw: true, body: "{not json" }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "invalid-json"); + + out = await callReclaim(route, { method: "POST", body: [] }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "invalid-body"); + + out = await callReclaim(route, { method: "POST", raw: true, body: "null" }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "invalid-body"); + + out = await callReclaim(route, { method: "POST", raw: true, body: "\"str\"" }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "invalid-body"); + } finally { + bench.close(); + } +}); + +test("reclaim POST validates olderThanDays as a JSON number, not a string", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + // POST 侧收的是 JSON 数字域:字符串 "3" 不是数字,必须拒——与 GET 的 query + // 域口径不同(那边只有字符串可收)。两边都放宽等于把「畸形」重新放进来。 + // NaN 不在此列表里:JSON 表达不了它,stringify 会把它变成 null,而 null 在这里 + // 与缺省同义(落默认窗口)——拿它当坏输入只会测到 null 那条规则。 + for (const bad of ["3", -1, 3.5, true, {}, []]) { + const out = await callReclaim(route, { method: "POST", body: { olderThanDays: bad } }); + assert.equal(out.status, 400, `olderThanDays=${JSON.stringify(bad)} 必须被拒`); + assert.equal(out.body.error, "invalid-older-than-days"); + } + // 量级也是「坏输入」:1e20 是整数,却会让 maintenance 算日期时抛 RangeError + // (#410 评审)。400 位数字不在此列——它化成 Infinity,而 JSON.stringify 把 + // Infinity 写成 null,落进「与缺省同义」那条规则(GET 侧才测得到它)。 + for (const huge of [1e20, 1e21, 3651]) { + const out = await callReclaim(route, { method: "POST", body: { olderThanDays: huge } }); + assert.equal(out.status, 400, `olderThanDays=${String(huge)} 必须被拒`); + assert.equal(out.body.error, "invalid-older-than-days"); + } + let ok = await callReclaim(route, { method: "POST", body: { olderThanDays: 0 } }); + assert.equal(ok.status, 200); + assert.equal(ok.body.olderThanDays, 0); + ok = await callReclaim(route, { method: "POST", body: { olderThanDays: 3650 } }); + assert.equal(ok.status, 200, "上界本身是合法的"); + assert.equal(ok.body.olderThanDays, 3650); + ok = await callReclaim(route, { method: "POST", body: { olderThanDays: null } }); + assert.equal(ok.status, 200); + assert.equal(ok.body.olderThanDays, 7, "null 与缺省同义:落默认窗口,不是 0"); + } finally { + bench.close(); + } +}); + +test("reclaim without the maintenance dependency answers 503 instead of crashing", async () => { + // 既有 14 处 createApi(...) 调用全是位置传参、不传第 9 个。它们必须继续工作: + // 「没接线」要如实报 unavailable,而不是把 undefined.reclaim 落成崩溃。 + const { routes } = setup("secret-token"); + const route = handler(routes, RECLAIM_PATH); + assert.ok(route, "路由注册与接线无关,少传依赖也要注册出来"); + + let out = await callReclaim(route); + assert.equal(out.status, 503); + assert.equal(out.body.error, "maintenance-unavailable"); + + out = await callReclaim(route, { method: "POST", body: {} }); + assert.equal(out.status, 503); + assert.equal(out.body.error, "maintenance-unavailable"); +}); + +test("reclaim maps a throwing maintenance.reclaim to 500 instead of leaking", async () => { + // 带 logger 跑:契约里 500 与 logger.warn 是一起要求的(没有日志的 500 在现场 + // 只剩一个「内部错误」,查不出是哪次回收炸的)。GET 那侧尤其要注意——外层 catch + // 也能兜成 500,所以只断言状态码抓不住内层 catch 的消失,必须看日志。 + const warnings = []; + const bench = reclaimSetup("secret-token", { logger: { warn: (m) => warnings.push(String(m)) } }); + try { + const route = handler(bench.routes, RECLAIM_PATH); + const original = bench.maintenance.reclaim; + bench.maintenance.reclaim = () => { throw new Error("database is locked"); }; + try { + let out = await callReclaim(route); + assert.equal(out.status, 500); + assert.equal(out.body.error, "internal"); + + out = await callReclaim(route, { method: "POST", body: { dryRun: false, confirm: true } }); + assert.equal(out.status, 500); + assert.equal(out.body.error, "internal"); + + // confirm-required 那条也要算上:它在 readBody 的回调里算 dry-run 报告,外层 try + // 那时已经退栈,漏了这道守卫就变成一个没人接的 rejection——客户端拿到的是「一个 + // 字节都没回」而不是 500(#410 评审指出的正是这条)。 + out = await callReclaim(route, { method: "POST", body: { dryRun: false } }); + assert.equal(out.status, 500, "confirm-required 的报告抛错必须回 500,不能无响应"); + assert.equal(out.body.error, "internal"); + + assert.equal(warnings.length, 3, "三条会调 reclaim 的路径各留一条 warn"); + for (const w of warnings) { + assert.match(w, /storage reclaim failed/); + assert.match(w, /database is locked/, "原始错误文本要留住,否则现场无从下手"); + } + } finally { + bench.maintenance.reclaim = original; + } + } finally { + bench.close(); + } +}); + +test("reclaim rejects non-GET/POST methods with 404", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + const spy = spyWrites(bench.store); + try { + for (const method of ["PUT", "DELETE", "PATCH", "HEAD"]) { + const out = await callReclaim(route, { method, body: { dryRun: false, confirm: true } }); + assert.equal(out.status, 404, `${method} 不该在这个路由上有语义`); + assert.equal(out.body.error, "not-found"); + } + assert.equal(spy.calls.clearDreamRunInputs, 0, "被 404 挡掉的方法一个字节都不能改"); + } finally { + spy.restore(); + } + } finally { + bench.close(); + } +}); + +test("reclaim report has no wrapper layer: fields sit at the top level", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + const out = await callReclaim(route); + assert.equal(out.status, 200); + // 报告形状由 src/maintenance.js 决定,路由只做搬运:加一层 { report } 会让 + // 面板与 standalone 两个客户端对不上同一个字段路径。 + assert.equal(typeof out.body.size.before_bytes, "number", "文件库必须给出真实体积"); + assert.ok(out.body.size.before_bytes > 0); + assert.equal(typeof out.body.size.freelist_pages, "number"); + assert.equal(typeof out.body.duration_ms, "number"); + assert.deepEqual(Object.keys(out.body.planned).sort(), ["archived_embeddings", "dream_run_inputs"]); + assert.deepEqual(Object.keys(out.body.vacuum).sort(), ["duration_ms", "error", "ran", "requested"]); + assert.ok("receipt" in out.body); + assert.ok("cutoff" in out.body); + } finally { + bench.close(); + } +}); + + +test("reclaim POST executes VACUUM when asked, and reports it ran", async () => { + const bench = reclaimSetup(); + try { + const archived = seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + // vacuum 是**执行时**才有的动作:dry-run 只报「会 VACUUM」,真执行才跑。这条锁住 + // 「勾了 VACUUM 却静默不跑」——请求体里带 true,报告里 requested 与 ran 都得是 + // true,否则用户以为文件缩回去了,其实只是清了列。 + const out = await callReclaim(route, { + method: "POST", + body: { dryRun: false, confirm: true, olderThanDays: 7, vacuum: true } + }); + assert.equal(out.status, 200); + assert.equal(out.body.dryRun, false); + assert.equal(out.body.vacuum.requested, true, "请求里的 vacuum:true 必须传到实现层"); + assert.equal(out.body.vacuum.ran, true, "确认执行时 VACUUM 必须真的跑"); + assert.equal(out.body.vacuum.error, null); + assert.equal(out.body.cleared.dream_run_inputs, 1); + assert.equal(bench.store.db.prepare("SELECT input FROM dream_runs WHERE id = 'api-old-run'").get().input, null); + assert.equal(bench.store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(archived.id).embedding, null); + } finally { + bench.close(); + } +}); + +test("reclaim confirm-required hands back a report for the same window it was asked about", async () => { + const bench = reclaimSetup(); + try { + seedReclaim(bench.store); + const route = handler(bench.routes, RECLAIM_PATH); + + // 确认的依据就是这份报告里的数字。窗口传了 30 却回一份 7 天的估算,用户就是照着 + // 错的数字按下去——对不可逆动作来说这是最坏的一种错位。 + const out = await callReclaim(route, { + method: "POST", + body: { dryRun: false, olderThanDays: 30, vacuum: true } + }); + assert.equal(out.status, 400); + assert.equal(out.body.error, "confirm-required"); + assert.equal(out.body.report.olderThanDays, 30, "报告必须复述调用方要的那个窗口"); + assert.equal(out.body.report.vacuum.requested, true, "报告必须描述将要执行的那个动作"); + assert.equal(out.body.report.dryRun, true, "拒绝执行时给出的仍是 dry-run 报告"); + } finally { + bench.close(); + } +}); + +test("wiring: maintenance is built before the panel createApi call and passed into it", () => { + // 接线锁。webServer 块在 index.js 里先于原来的构造点执行,实例一旦建晚了,面板 + // 路由拿到的就是 null——功能「看起来加了」但每次都回 503。 + const src = readFileSync(join(root, "src", "index.js"), "utf8"); + const buildAt = src.indexOf("createMaintenance({"); + const callAt = src.indexOf("createApi(ctx, service, settings"); + assert.ok(buildAt >= 0, "src/index.js 里找不到 createMaintenance 构造"); + assert.ok(callAt >= 0, "src/index.js 里找不到面板 createApi 调用"); + assert.ok(buildAt < callAt, "维护实例必须在面板 createApi 之前构造"); + assert.match(callText(src, callAt), /maintenance/, "面板 createApi 调用必须把维护实例传进去"); + + // 另一头:standalone 那条路(既有功能)不能因为上移而丢掉。 + const standaloneAt = src.indexOf("createStandaloneApi({"); + assert.ok(standaloneAt >= 0); + assert.match(callText(src, standaloneAt), /maintenance/); +}); + diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index acecb2f..d4e20b7 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -1338,6 +1338,195 @@ test("panel can create a memory by hand: menu entry wired to the dialog", () => assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`); } }); +// --- issue #386 第 4 条:存储回收卡(dry-run 预估 + 两步确认执行)--- +// 这是「不可逆的内容丢弃」在面板上的唯一入口,所以锁的不是「有没有这张卡」,而是三条 +// 不变量:执行必须两步、POST 必须逐字带确认门、VACUUM 失败不得渲染成干净的成功。 +// 所有断言都做在**卡片切片**上:路由串/dryRun/confirm 这些字面量在整包里到处都是, +// 在 clientSource 上做 includes 恒真、等于没锁(#408 评审踩过的坑)。 +const maintCardSource = () => { + const start = clientSource.indexOf("function MaintenanceCard"); + const end = clientSource.indexOf("function MirrorHealthCard", start); + assert.ok(start >= 0 && end > start, "MaintenanceCard must be findable in the bundle (切片锚点变了?)"); + return clientSource.slice(start, end); +}; + +test("maintenance card: i18n keys are bilingual and the section is really rendered", () => { + for (const key of [ + "memory.status.sec.maintenance", + "memory.status.maint", + "memory.status.maint.freelist", + "memory.status.maint.plannedInputs", + "memory.status.maint.plannedEmbeddings", + "memory.status.maint.window", + "memory.status.maint.windowUnit", + "memory.status.maint.windowLabel", + "memory.status.maint.reestimate", + "memory.status.maint.reestimating", + "memory.status.maint.stale", + "memory.status.maint.vacuum", + "memory.status.maint.vacuumHint", + "memory.status.maint.execute", + "memory.status.maint.confirm", + "memory.status.maint.running", + "memory.status.maint.cancel", + "memory.status.maint.confirmHint", + "memory.status.maint.badWindow", + "memory.status.maint.failed", + "memory.status.maint.done", + "memory.status.maint.size", + "memory.status.maint.reclaimed", + "memory.status.maint.clearedInputs", + "memory.status.maint.clearedEmbeddings", + "memory.status.maint.receipt", + "memory.status.maint.receiptNone", + "memory.status.maint.duration", + "memory.status.maint.vacuumRan", + "memory.status.maint.vacuumOff", + "memory.status.maint.vacuumFailed", + "memory.status.maint.vacuumFailedHint" + ]) { + // 数的是**字典行**(`"key": "` 顶格成行),不是裸字符串出现次数。裸数会把调用点 + // 一起算进来:`t("...maint.done")` 在卡片里本来就有一次,删掉一整行英文译文之后 + // 「字典 zh 行 + 调用点」照样凑够 2——锁恒真,就是这么漏的(本条第一版被 M1 变异 + // 抓出来了)。行首锚定后,少一侧译文就只剩 1。 + const entries = clientSource.match(new RegExp(`^\\s*"${key.replace(/\./g, "\\.")}"\\s*:\\s*"`, "gm")); + const count = entries ? entries.length : 0; + assert.equal(count, 2, `i18n key ${key} must appear as exactly one dictionary line per language (got ${count})`); + } + // 分区键只在字典里躺着不算加分区:必须真的渲染出「维护」这段扫读锚点。 + assert.ok(clientSource.includes('h("div", { className: "mneme-statushead" }, t("memory.status.sec.maintenance"))'), + "the maintenance section head must render from the dictionary key"); +}); + +test("maintenance card: the reclaim route is the in-process one, not the standalone data plane", () => { + const src = maintCardSource(); + // 走 /api/dsh-mneme/... 这条 in-process 路由:它才有 requireAuth + 与 CLI 同一条 + // reclaim 实现。写成 standalone 数据面的路径会绕开面板身份的鉴权口径。 + assert.equal(src.split('"/api/dsh-mneme/maintenance/reclaim"').length - 1, 1, + "the card must name the in-process reclaim route exactly once (via its path constant)"); + // 预估走 GET 且把窗口作为查询参数带上;粘在按钮上——键入即发会让数字在半截输入时跳。 + assert.ok(src.includes("`${RECLAIM_PATH}?olderThanDays=${days}`"), + "re-estimating must GET the route with the current window as a query parameter"); + assert.equal(src.split("onClick: estimate").length - 1, 1, + "only the re-estimate button may trigger an estimate (no request per keystroke)"); + assert.ok(src.includes('onChange: (e) => setDays(e.target.value)'), + "typing in the window field must only update local state"); +}); + +test("maintenance card: execution carries the verbatim dryRun:false + confirm:true gate", () => { + const src = maintCardSource(); + // 逐字锁请求体:多一个字段、少一个空格、把 Number(days) 换成裸 days 都匹配不上。 + // 锁裸字段名(dryRun/confirm)会恒真——它们在这段源码里各自还有别的出现位置。 + assert.match(src, + /JSON\.stringify\(\{ dryRun: false, confirm: true, olderThanDays: Number\(days\), vacuum \}\)/, + "the executing body must be exactly dryRun:false + confirm:true + olderThanDays + vacuum"); + // 全卡只有一处能改库:确认门之外的任何一次 POST 都是「第一次点就真清了」。 + assert.equal(src.split('method: "POST"').length - 1, 1, "the card must issue exactly one POST"); + assert.equal(src.split("dryRun: false").length - 1, 1, "exactly one mutating body may exist"); + // 窗口非法时不得发请求:0 在服务端是「清掉全部快照」,畸形输入绝不能静默落进最坏解释。 + assert.ok(src.includes('if (!/^\\d+$/.test(days)) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'), + "a non-numeric window must be rejected locally before any round trip"); + // 上界同样在本地拦:/^\d+$/ 放得过 1e20 与 400 位数字(后者化成 Infinity),后端 + // 会回 400,但那时用户只看到一句「回收失败」,分不清是窗口不合法还是网络问题。 + assert.match(src, /const MAX_WINDOW_DAYS = 3650;/, + "the card must carry the same upper bound as the backend"); + assert.ok(src.includes('if (Number(days) > MAX_WINDOW_DAYS) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'), + "an out-of-range window must be rejected locally, before any round trip"); +}); + +test("maintenance card: 'no receipt' does not claim a single cause", () => { + // writeReceipt 返回 null 有两种成因:审计被关(config.llmAudit.enabled === false), + // 或回执写库失败(saveLlmAudit 抛错,只 warn)。旧文案「无(审计未启用)」把第二种 + // 情况说成第一种——而这行出现在不可逆操作的结果区,用户会照它去对账。 + for (const wording of [ + '"memory.status.maint.receiptNone": "未记录(审计未启用或回执写入失败)"', + '"memory.status.maint.receiptNone": "Not recorded (audit disabled or receipt write failed)"' + ]) { + assert.ok(clientSource.includes(wording), + `the missing-receipt wording must name both causes: ${wording}`); + } +}); + +test("maintenance card: no window.confirm — the host webview cannot be relied on for it", () => { + const src = maintCardSource(); + assert.equal(/window\.confirm/.test(src), false, + "window.confirm is not reliably available in the host webview; the in-card confirm state is the gate"); + assert.equal(/(^|[^.\w])confirm\s*\(/.test(src), false, + "no blocking confirm() call may exist in the card (the confirm state is a rendered branch)"); + assert.ok(src.includes("mneme-btndangerconfirm"), + "the armed state must use the repo's existing danger-confirm styling"); +}); + +test("maintenance card: mounted in the status view under the maintenance section", () => { + // 组件存在但没挂上树 = 用户永远看不到,正是这条 issue 报的缺口。 + const panelStart = clientSource.indexOf("function StatusPanel"); + const panelEnd = clientSource.indexOf("function ", clientSource.indexOf("h(WorkbenchSection", panelStart)); + assert.ok(panelStart >= 0 && panelEnd > panelStart, "StatusPanel must be findable in the bundle"); + const panel = clientSource.slice(panelStart, panelEnd); + assert.ok(panel.includes("h(MaintenanceCard, { t })"), + "the status panel must render the maintenance card"); + assert.ok(panel.includes('t("memory.status.sec.maintenance")'), + "the card must sit under the maintenance section head, not loose in the grid"); +}); + +test("maintenance card: success state exists only behind the confirm gate", () => { + const src = maintCardSource(); + // 第一次点只能「武装」,不得直连执行:默认态是红描边钮(mneme-btndanger),只有武装 + // 后的实心红确认钮(mneme-btndangerconfirm)才允许调用 execute。 + assert.match(src, /mneme-btndangerconfirm"[\s\S]{0,200}?onClick: execute/, + "only the armed danger-confirm button may fire the mutating call"); + assert.match(src, /mneme-btndanger"[\s\S]{0,200}?onClick: \(\) => setArmed\(true\)/, + "the first click must only arm the confirm state, never execute"); + assert.equal(src.split("onClick: execute").length - 1, 1, + "the executing click must have exactly one entry point in the card"); + // 非 2xx 必须先 return,成功态才能写在它之后——否则失败也会渲染成成功。 + const guard = src.indexOf("if (!r.ok) {"); + const success = src.indexOf("setResult(r.body)"); + assert.ok(guard >= 0 && success > guard, + "a non-2xx response must return before the success report is written"); + assert.equal(src.split("setResult(r.body)").length - 1, 1, + "the success report must be written exactly once, from the confirmed call"); + // 窗口改过却没重新估算时,确认态里摆出来的会是上一次的数字——对不可逆动作来说这是最坏 + // 的一种错位,所以那种状态下执行钮必须是禁用的。 + assert.ok(src.includes("const stale = !report || String(report.olderThanDays) !== days;"), + "an estimate that no longer matches the input must be detectable"); + assert.match(src, /disabled: running \|\| loading \|\| estimating \|\| stale/, + "the run button must stay disabled while the estimate is stale"); +}); + +// VACUUM 失败 ≠ 成功(契约 §5 的硬要求)。清理的两个 UPDATE 各自提交且不可逆,VACUUM +// 只负责把文件缩回去;把半成功渲染成成功,用户会以为白清了一次。这条锁住那个三元分支, +// 防止未来被「简化」成只看 dryRun/receipt。 +test("maintenance card: a failed VACUUM is reported, never rendered as a clean success", () => { + const src = maintCardSource(); + assert.ok(src.includes("const vacuumError = rv.error == null ? null : String(rv.error);"), + "vacuum.error is string|null and must be read as the failure signal it is"); + assert.match(src, /vacuumError \? t\("memory\.status\.maint\.vacuumFailed"\) : t\("memory\.status\.maint\.done"\)/, + "a non-null vacuum.error must replace the success headline"); + assert.ok(src.includes('t("memory.status.maint.vacuumFailedHint")'), + "the failed state must state that the reclaim already happened, is irreversible, and that a rerun is idempotent"); + // receipt 也要露面:不可逆动作没有回执号,出事时无法对账。锁的是**取值表达式**而不是 + // 行标签——`t("...maint.receipt")` 只是左侧那行文案,把右侧渲染删空它照样在,锁标签 + // 等于没锁(M12 变异验证过)。 + assert.match(src, /result\.receipt == null \? t\("memory\.status\.maint\.receiptNone"\) : String\(result\.receipt\)/, + "the receipt value must be rendered (id when present, an explicit 'not recorded' when absent)"); +}); + +test("maintenance card: VACUUM defaults to off and a failed run is reported verbatim", () => { + const src = maintCardSource(); + // VACUUM 会短暂独占数据库(别的进程此时写入会 SQLITE_BUSY),它是**可选**动作, + // 默认必须关着——默认开等于每次回收都替用户独占一次库。契约 §5 的硬要求。 + assert.ok(src.includes("const [vacuum, setVacuum] = useState(false);"), + "VACUUM must default to off"); + // 非 2xx 必须把服务端给的 error 串如实带出来:只说「失败」,现场就分不清是 400 的 + // confirm-required 还是 500 的 internal。 + assert.ok(src.includes('setResultError(`${t("memory.status.maint.failed")} · ${(r.body && r.body.error) || `HTTP ${r.status}`}`);'), + "a non-2xx response must surface the server's own error string, not a bare 'failed'"); + // 执行成功后必须复位确认态:否则下一次点击落在已武装的分支上,一步就把不可逆动作 + // 放出去,两步确认名存实亡。 + assert.match(src, /setResult\(r\.body\);\s*setArmed\(false\);/, + "the confirm state must be reset on the success path"); +}); // 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含), // 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进 // 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛