@sidady/dsh-steward 0.9.12 → 0.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCEPTANCE.md +88 -25
- package/README.md +118 -2
- package/lib/actions.js +49 -1
- package/lib/client.js +72 -16
- package/lib/index.js +65 -13
- package/lib/report.js +41 -2
- package/package.json +1 -1
- package/smoke.mjs +100 -2
package/ACCEPTANCE.md
CHANGED
|
@@ -37,38 +37,67 @@
|
|
|
37
37
|
> 还有一条**静态判据**的用法:像「渲染处不许把 ISO 的 T 换成空格」这类规约(v0.9.11),
|
|
38
38
|
> 与其等它在界面上出错,不如直接扫源码——但静态判据必须**验证过会红**,否则它只是装饰。
|
|
39
39
|
|
|
40
|
-
> **2026-11-
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
>
|
|
44
|
-
>
|
|
45
|
-
>
|
|
40
|
+
> **2026-11-11 六次修订(v0.9.14):基线升到 v0.9.14 / 292 断言,新增 A15 与 A16 两行。**
|
|
41
|
+
> v0.9.13 与 v0.9.14 修的是同一个真机故障的两层:面板画得出来、每个请求都 404。
|
|
42
|
+
> 第一层(v0.9.13)是**说法**问题——把 404 翻译成「接口没挂上」,但它把原因只归给了热重载。
|
|
43
|
+
> 第二层(v0.9.14)才是**病因**:冷启动时 `webServer` 与 `tools` 的 apply 排在本插件之后,
|
|
44
|
+
> 旧的 `getService(ctx,'webServer')` 一次性探测拿到 null,路由与两个工具都没注册,
|
|
45
|
+
> 而三处注册外面都包着 fail-soft `try/catch`,于是**失败完全无声**。
|
|
46
|
+
> 这两行分别钉住「说得准」和「挂得上」:
|
|
47
|
+
> - **A15**「路由/工具是否注册成功,能不能被看见」——错把「面板画出来了」当成「插件在工作」。
|
|
48
|
+
> - **A16**「冷启动与热重载是不是被当成同一个病因」——症状相同、修法不同,说错会让人白等一次重启。
|
|
46
49
|
>
|
|
47
|
-
|
|
50
|
+
> 还有一条更一般的教训值得记在这里:**fail-soft 是正确设计,但它的代价必须是可观测**。
|
|
51
|
+
> 这次不是注册逻辑写错了,是「注册失败」这个事实没有任何出口——面板照常渲染、`ping` 照常回
|
|
52
|
+
> `{"ok":true}`、日志里只有一行 warn。**一个不会喊的守卫,和没有守卫是一样的。**
|
|
53
|
+
|
|
54
|
+
> **2026-11-11 七次修订(v0.9.15):基线升到 v0.9.15 / 313 断言,新增 A17–A21 五行。**
|
|
55
|
+
> 这一轮不是修 bug,是把职能设定里「本该有、却一直缺」的几件补齐:
|
|
56
|
+
> - **P0**:`setSaved` 把 UTC 切片当本地时间贴给东八区(差 8 小时),而静态判据只认单引号写法、
|
|
57
|
+
> 双引号绕过去了;`ui.autoScanMinutes` 是个死设置——界面显示「已保存」、行为永远按硬编码的 10 分钟。
|
|
58
|
+
> - **P1**:只有「静音」没有「这条判错了」入口,两周验收只能人肉回看,管家学不会;
|
|
59
|
+
> 现在 `feedback.json` + plan→confirm→audit 同一条链,判错后降 40 分、不再计直达处置、可撤销、不过期。
|
|
60
|
+
> - **P2**:趋势页只是流水行、只报坏不报好、15 个 finding 里 10 个没有直达(其中一条还指向不存在的动作卡)、
|
|
61
|
+
> 自检结果只在 `ping` 里。现在服务端算好 `trendVerdict` 的人话、好转/连续恶化会主动弹、
|
|
62
|
+
> 面板显示 `wiring`、删掉那条假直达并补上 `backup-freshness → backup`。
|
|
63
|
+
>
|
|
64
|
+
> 五行的共同点:**它们都是「界面上看着有、其实没接上」**——设置一个值不生效、一个按钮点不到东西、
|
|
65
|
+
> 一个时间差 8 小时、一条结论没人说。这类缺陷不会让体检报错,只会让人对面板失去信任。
|
|
66
|
+
> 所以 A17–A21 一律判「接没接上 / 说没说清」,不判「代码写得对不对」。
|
|
67
|
+
|
|
68
|
+
## 基线快照(2026-11-11 19:40 采集(v0.9.15 重采),回看时用来对照)
|
|
48
69
|
|
|
49
70
|
| 项 | 基线值 |
|
|
50
71
|
|---|---|
|
|
51
|
-
| bundle 版本 | `@sidady/dsh-steward` v0.9.
|
|
52
|
-
|
|
|
72
|
+
| bundle 版本 | `@sidady/dsh-steward` v0.9.15,installed + enabled;ping 回报 `version:"0.9.15"` 且 `wiring.route:"registered"`(三条 via 全 inject) |
|
|
73
|
+
| npm 上的版本 | **`0.9.14`**(`dist-tags.latest`)——本地已是 0.9.15,**尚未 publish**,见下「版本号与基线数字的关系」 |
|
|
74
|
+
| GitHub | `stallenlu/dsh-steward` public,`main` 最新提交 `e3ef16d`(docs 重采到 v0.9.14);v0.9.15 代码待 commit + push |
|
|
75
|
+
| 目录条目 | awesome-dsh-plugin PR **#14042** 开着;仓库年龄门 2026-11-11T15:04:07Z 开,之后第一次 regate 北京 2026-11-12 02:19;合并后 dshmarket 才拉得到 |
|
|
76
|
+
| 本地验证 | 9 个 JS `node --check` 全过,`smoke.mjs` **313 passed / 0 failed**(含 21 条 v0.9.15 静态判据,7 条反向验证过会红) |
|
|
53
77
|
| 体检结论 | `level=warning`,`counts={critical:0, warning:2, info:10, muted:0}`,`errors=[]`,共 12 条 findings |
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
78
|
+
| 两条 warning | `plugin-change`(本会话记录到 1 次插件变更,属事实非异常)+ `data-at-risk`(易失数据不在默认备份分区,长期项) |
|
|
79
|
+
| `trendVerdict` | **新增字段**:`{state:"stable", worseningStreak:0, text:"与上一次体检相比没有明显变化。"}`;服务端算好人话、客户端只渲染 |
|
|
80
|
+
| `autoScanMinutes` / `wiring` | report 返回体新增:`autoScanMinutes: 10`、`wiring.route:"registered"`;面板生成时间行下显示「接口已挂上 · 路由与工具随服务就绪后注册」 |
|
|
81
|
+
| crash 日志 | **仍 4 份**,最晚 `crash-2026-11-09T23-28-38-158Z-web-boot.log`;v0.9.13 起**零新增** |
|
|
82
|
+
| audit.jsonl | 40 行(2000 行上限) |
|
|
83
|
+
| trend.json | **4 个点**,`cFreeBytes` 本轮 ≈74.8 GB(80363397120 B) |
|
|
84
|
+
| acked.json / feedback.json | `[]` / **尚不存在**(v0.9.15 新增,没有用户点过「这条判错了」就不会建文件;首次标记后才有) |
|
|
85
|
+
| 工作区 | 7 个 / 合计约 **232 MB**(采集时 243226827 B;只看「7 个」与增长判定,不要咬死体积) |
|
|
86
|
+
| 插件更新 | 比对 11 个已装包:**0 个落后、1 个缓存偏旧、0 个无法比较** |
|
|
60
87
|
| 数据源覆盖 | `coverage` **7/7** = `data`,`unavailable=[]`,`errors=[]` |
|
|
61
|
-
| 配置指纹 | `cordis.patch.yml` `
|
|
62
|
-
| 设置 | `
|
|
63
|
-
| 客户端包 | 38
|
|
64
|
-
| 默认模型 |
|
|
65
|
-
| 加密备份 | 2 份,最新 `dsh-config-2026-11-09-1749ba.zip`(
|
|
88
|
+
| 配置指纹 | `cordis.patch.yml` `40c7de15f6a7500d`(82811 B / 2720 行) / `profile package.json` `bc95db08d316c10c`(2837 B / 87 行)。**patch 体量在变**(60159→89178→82811 B):回看只比 `- id:` 块数有没有减少,不要比字节 |
|
|
89
|
+
| 设置 | `savedAt` 沿用旧值;新增的活设置 `ui.autoScanMinutes=10` 已穿透到 report 返回体与客户端阈值 |
|
|
90
|
+
| 客户端包 | 38 个声明 `dsh.client`,checked 38 / unreadable 0 / withoutId 0;`inject` 只声明 `@deepseek-ai/dsh-client-ui-slots` |
|
|
91
|
+
| 默认模型 | `model-health`:`state:"ok"`、4096 ms;默认 `workbuddy/hy4free` 可用 |
|
|
92
|
+
| 兼容 / 加密备份 | 11 个声明门槛的包都适配宿主 `0.2.2-rc.1`;加密备份 2 份,最新 `dsh-config-2026-11-09-1749ba.zip`(100382581 B / 95.7 MB) |
|
|
66
93
|
|
|
67
94
|
> **版本号与基线数字的关系**:本表的数字是「当天真实跑出来的」,不是「必须永远等于这个」;
|
|
68
95
|
> 回看时若断言数已经涨了(比如又加了几条),那是覆盖变多,记一笔新数字即可,不算退化。
|
|
69
|
-
> 真正要盯的是:crash 日志有没有新增、`errors` 是否仍为空、`coverage` 是否仍 7/7
|
|
70
|
-
>
|
|
71
|
-
>
|
|
96
|
+
> 真正要盯的是:crash 日志有没有新增、`errors` 是否仍为空、`coverage` 是否仍 7/7、
|
|
97
|
+
> `ping.wiring.route` 是否仍是 `registered`、`trendVerdict` / `autoScanMinutes` / `wiring` 三个返回体字段是否还在。
|
|
98
|
+
> **回看前先做一件事**:把 `package.json` 的 version、npm 上的 latest、ping 的 version 三个对一下。
|
|
99
|
+
> 三者不等说明「磁盘改了 / 发了 / 宿主没重载」里有一环断了——此时面板看到的是旧代码,任何 A 项的结论都不作数。
|
|
100
|
+
> **本次基线三者不等是已知状态**:本地 0.9.15 / npm 0.9.14 / ping 0.9.15,因为 0.9.15 还没发。发布后应回到三者一致。
|
|
72
101
|
|
|
73
102
|
## 检查表
|
|
74
103
|
|
|
@@ -100,6 +129,13 @@
|
|
|
100
129
|
| A12 | 模型健康是否把「忙」当「坏」 | 报告 `model-health` 的 `data.state` + `deadModels` | 只把 404/422(上游明确拒绝路由)与「连续两轮拿不到内容」算坏;429/5xx/超时/空回复一律记 busy 且不升 warning、不进 `deadModels`。若某个免费模型只是一时冷却却被写成「已不存在」→ ❌(这是最容易被免费额度误伤的一项) |
|
|
101
130
|
| A13 | 「多久以前」与「比启动早多久」有没有被混成一个数 | 报告 `boot-crash-latest` 的 `data.ageHours` / `bootLagHours` / `ageText` / `bootLagText` | 四个数各司其职:`ageHours` 是**距现在**多久、`bootLagHours` 是**距本次启动**多久,两者可以相差很大(真机 7.3 小时 vs 0.01 小时)。标题/正文里出现「约 0 小时前」而事件实际已过去数小时 → ❌(v0.9.10 前就是这样)。同类要一并查:列表截断有没有说出「另有 N 个」(工作区 / 备份 / 采集告警,v0.9.10) |
|
|
102
131
|
| A14 | 配色是不是又从 baseline 溜回自造色 | `node check.mjs ../dsh-steward/lib/client.js` + `smoke.mjs` 的配色静态判据 | 两个都必过:checker 报 `no hardcoded neutral` 不给 error 只说明「没有硬编码的灰」,**抓不到「自造一套蓝」**(那是另一种饱和度,不是灰);所以真正的判据是 smoke 那 7 条 —— baseline 块外不许出现非中性的 `rgba()/rgb()`、不许出现 `var(--dsw` 之外的裸 hex、选中态必须用 `C.solid`/`C.on` 反相。若面板上又出现 `rgba(90,159,212,…)` 这类蓝、或选中态靠变蓝表示 → ❌(v0.9.12 前就是这样,12 处) |
|
|
132
|
+
| A15 | 路由与工具是不是「注册了但没说」 | `ping` 的 `wiring.route` / `wiring.via` + 面板能不能出报告 | 必须 `wiring.route === "registered"`,且 `wiring.via` 里 `systemPrompt` / `webServer` / `tools` 三条都是 `inject`。若面板画得出来但每个请求 404、`dsh_steward_*` 工具又「未知工具」→ ❌(v0.9.14 前就是这样:冷启动竞态下三处注册全被静默跳过,而 `ping` 还回 `{"ok":true}`) |
|
|
133
|
+
| A16 | 冷启动与热重载有没有被当成同一个病因 | 面板 404 时的提示文案 + 复现条件 | 两种情境都要能讲对:**热重载**=改插件文件后旧路由被销毁;**冷启动**=`webServer` 比插件晚 apply。文案里只写其中一种 → ❌(v0.9.13 只写热重载,于是用户重启后看到同一句提示,等于被指错了方向)。判据不看措辞好不好听,看它有没有把「我刚重启过」这个人排除掉 |
|
|
134
|
+
| A17 | 本地时间有没有被 UTC 冒充(两种引号都查) | `smoke.mjs` 的 v0.9.11 / v0.9.15 静态判据 + 面板「设置」的保存时间 | 判据必须同时认单引号与双引号的 `.replace("T"," ")` 写法,且 `setSaved` 走 `localStamp()`。若把保存时间显示成 UTC(比实际差 8 小时)、或改回 `.replace("T"," ")` 后静态判据仍全绿 → ❌(v0.9.15 前双引号绕过判据) |
|
|
135
|
+
| A18 | 设置项是不是真的接上了(**抓死设置**) | report 返回体 `autoScanMinutes` + 客户端 `useEffect` 阈值 | 改 `ui.autoScanMinutes` 后:①report 返回体里的值跟着变;②切回面板时的刷新阈值跟着变(不许再出现硬编码 `10 * 60 * 1000`)。若界面显示「已保存」而行为不变 → ❌(v0.9.15 前就是这样) |
|
|
136
|
+
| A19 | 误报反馈闭环是否成立 | `feedback.json` + 面板「这条判错了 / 撤销判错」 + `audit.jsonl` | 四件都要真:①点「判错」走 plan→confirm→audit 同一条链、有 hash;②该 finding 打上 `falsePositive`、score 降 40、不再计直达处置、证据仍保留;③「撤销」能恢复;④条目**不过期**(静音会过期,判错不该悄悄重新武装)。任何一件没接上 → ❌ |
|
|
137
|
+
| A20 | 趋势结论与好转通知是不是服务端算的人话 | report `trendVerdict` + 面板「审计趋势」结论行 + ToastHost | `trendVerdict` 必须有 `state`(worsening / recovered / stable)、`worseningStreak`、中文 `text`,由服务端 `summarizeTrend()` 算好、客户端只渲染;好转或连续 ≥2 次变差要主动弹一条(用 `generatedAt` 去重,不许反复弹)。若趋势页仍只是流水行、或只报坏不报好 → ❌ |
|
|
138
|
+
| A21 | 直达按钮不造假 + 自检结果用户可见 | `ACTION_FOR` + 动作卡清单 + 面板生成时间行 | ①`ACTION_FOR` 里每一条映射都要有对应的动作卡(v0.9.15 删掉了指向不存在卡片的 `model-health → model-default`,补上 `backup-freshness → backup`);②`wiring.route` 要显示在面板上,没挂上时给红字与恢复办法,不能只藏在 `ping` 里。若点了「去看看」跳到一个不存在的动作、或路由挂不上却面板无提示 → ❌ |
|
|
103
139
|
|
|
104
140
|
### B. 可回溯(每次动作都能查账)
|
|
105
141
|
|
|
@@ -114,7 +150,7 @@
|
|
|
114
150
|
| # | 检查项 | 数据源 | 打勾条件 |
|
|
115
151
|
|---|---|---|---|
|
|
116
152
|
| C1 | 没有代跑提权命令 | 会话记录 + `audit.jsonl` | 全期零次 `DISM` / `diskpart compact` / 卸载程序被 Agent 执行(只呈现过建议)→ ✅ |
|
|
117
|
-
| C2 | 没有「未经授权就动手」 | `audit.jsonl` 每条 `kind` ∈ {clean,backup,disable,enable,knowledge,ack,unack,model-default} 且都有 `hash` | 不存在 hash 为空却 `ok:true` 的执行行 → ✅ |
|
|
153
|
+
| C2 | 没有「未经授权就动手」 | `audit.jsonl` 每条 `kind` ∈ {clean,backup,disable,enable,knowledge,ack,unack,model-default,feedback,unfeedback} 且都有 `hash` | 不存在 hash 为空却 `ok:true` 的执行行 → ✅ |
|
|
118
154
|
| C3 | 口令红线:11-16 周提醒只提醒不代做 | 会话记录(2026-11-16 10:00 那次触发) | Agent 没有索要口令、没有代跑、没有提议代跑 → ✅;任何一项违反 → ❌ 并回写 USER.md |
|
|
119
155
|
| C4 | 坏请求不会触发写动作 | 对路由发畸形 body | `{oops` / `{}` / 超 16 KB / JSON 数组 必须全部 400,且**不得**因此跑一次体检(v0.9.8 前会把空 action 当 report)→ ✅ |
|
|
120
156
|
|
|
@@ -122,7 +158,7 @@
|
|
|
122
158
|
|
|
123
159
|
| # | 检查项 | 数据源 | 打勾条件 |
|
|
124
160
|
|---|---|---|---|
|
|
125
|
-
| D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线
|
|
161
|
+
| D1 | 依赖缺失只降级不崩溃 | `node smoke.mjs` 的 fail-soft 断言 | 回看当天跑一次 smoke,仍全绿(基线 313 passed / 0 failed)→ ✅(若期间又加断言,以当天全绿为准并记下新数字) |
|
|
126
162
|
| D2 | 热挂载后落库一致 | profile `package.json` + `/api/dsh-steward/` 200 + ping 版本 | grep `dsh-steward`=2、路由 200、ping 的 `version` = `package.json` 的 `version` 三者一致 → ✅。**注意**:`remove_bundle` 会因 `bundle-in-use` 失败,实际路径是 `set_bundle enabled:false → true`;ping 版本正确也**不**等于新代码已加载(读的是磁盘 package.json),要看新文案是否出现 |
|
|
127
163
|
| D3 | 可选数据源缺失不拖垮报告 | 报告 `status` 与 `coverage` | 缺一个可选源时 `status` 仍是 `ok`、只在 `coverage` 里记 `unavailable` → ✅ |
|
|
128
164
|
| D4 | 慢请求不会无限等待 | 面板发一次 `model-scan` | 服务端 3 路并发(12 探针最坏 ≈180 s),前端 `report`/`model-scan` 预算 240 s、普通动作 30 s。若面板转圈超过 5 分钟仍无任何提示 → ❌ |
|
|
@@ -158,6 +194,18 @@ curl -s -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-typ
|
|
|
158
194
|
|
|
159
195
|
# 5) 坏请求不该跑体检(C4)
|
|
160
196
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{oops'
|
|
197
|
+
|
|
198
|
+
# 6) 路由到底挂上没有(A15)——比「面板能不能画出来」可靠得多
|
|
199
|
+
# 期望:version 三方一致,且 wiring.route === registered
|
|
200
|
+
# wiring.via 里 systemPrompt / webServer / tools 三条都是 inject
|
|
201
|
+
curl -s -o "$TMP/ping.json" -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{"action":"ping"}'
|
|
202
|
+
cat "$TMP/ping.json"
|
|
203
|
+
|
|
204
|
+
# 7) v0.9.15 新增字段(A18–A21)——确认「接上了」而不只是「写好了」
|
|
205
|
+
curl -s -o "$TMP/rep.json" -X POST http://127.0.0.1:<当前端口>/api/dsh-steward/ -H 'content-type: application/json' -d '{"action":"report"}'
|
|
206
|
+
node -e "const r=require(process.argv[1]); console.log('autoScanMinutes:',r.autoScanMinutes,'wiring:',r.wiring.route,'trendVerdict:',r.report.trendVerdict.state,r.report.trendVerdict.text)" "$TMP/rep.json"
|
|
207
|
+
# 误报反馈:点一次「这条判错了」后应出现 feedback.json,且该 finding 带 falsePositive、score 降 40
|
|
208
|
+
cat ~/.dsh/dsh-steward/feedback.json 2>/dev/null || echo '(还没有判错记录)'
|
|
161
209
|
```
|
|
162
210
|
|
|
163
211
|
> 端口每轮启动都会变,用运行时上下文里的 GUI URL,不要沿用旧端口。
|
|
@@ -194,8 +242,23 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<当前端口>
|
|
|
194
242
|
- **依赖声明必须与代码一致**:`dsh.client.inject` 里声明了却零调用的包就是虚挂,它今天不报错、将来宿主改注入方式时会以「找不到依赖」的形式炸掉(v0.9.10 去掉了 locale)。判定方式是 grep 实际调用,不是看别的插件怎么写。
|
|
195
243
|
- **静态判据也必须验证会红**:像「渲染处不许把 ISO 的 T 换成空格」这种扫源码的断言,写完要故意改回旧写法确认它 FAIL,否则它只是装饰(v0.9.11 做了这次反向验证)。这条对所有 `smoke.mjs` 里的规约型断言都适用。
|
|
196
244
|
- **测试全绿不等于包能装**:`files` 白名单漏一个文件时,从源码树跑的断言全绿、装包的人一启动就 `ERR_MODULE_NOT_FOUND` 并拖垮整个 Web UI(v0.9.9 的 `lib/models.js`)。发布前必须跑一次 `npm pack` 并解包核对相对 import(D5)。
|
|
245
|
+
- **fail-soft 的代价必须是可观测**:
|
|
246
|
+
注册失败、依赖缺失这类「不该拖垮启动」的降级是正确的,但如果它连一条能查到的状态都不留,
|
|
247
|
+
就等于没有守卫。v0.9.14 之前三处注册全包在 `try/catch` 里只 warn 一行,
|
|
248
|
+
结果面板照常渲染、`ping` 照常回 `{ok:true}`、路由却从未注册——
|
|
249
|
+
**一个不会喊的守卫和没有守卫是一样的**。
|
|
250
|
+
判据:任何 fail-soft 分支都要能把「我降级了」暴露到 ping / 报告 / 日志三处里至少一处(A15)。
|
|
197
251
|
- **info 不算打扰,warning 才算**:判误报时先看 severity。info 天天在、你从不理它,是噪音的候选而非误报;warning 天天在、你无事可做,就是误报。
|
|
198
252
|
- 出现 ❌ 时不改判定标准,只记原因;判定标准本身要改,必须写下「为什么原标准不可执行」,不许事后放宽。
|
|
253
|
+
- **「界面上有」不等于「接上了」**:一个设置项显示「已保存」但行为按硬编码走、一个按钮指向不存在的动作卡、
|
|
254
|
+
一个时间差 8 小时、一条趋势结论没人说——这些都不让体检报错,只让人对面板失去信任。
|
|
255
|
+
A17–A21 一律判「接没接上 / 说没说清」,不判「代码写得对不对」。
|
|
256
|
+
- **静态判据本身也要防绕过**:v0.9.11 那条「不许把 ISO 的 T 换成空格」只认单引号,v0.9.15 前双引号写法
|
|
257
|
+
就绕过去了。规约型断言写完要问一句「换一种引号 / 换一种写法还能不能抓到」,并反向验证会红。
|
|
258
|
+
- **判错不是静音**:静音是「我知道了先不修」(会过期、会回来),判错是「这条结论本身错了」
|
|
259
|
+
(不过期、降权、但证据保留)。两者混成一个入口,两周验收就学不到东西(A19)。
|
|
260
|
+
- **好转也要说**:只报坏不报好,用户永远不知道管家在变好还是在变差;但好转通知必须去重,
|
|
261
|
+
否则每次体检都弹一次,本身就成了新的噪音(A20)。
|
|
199
262
|
|
|
200
263
|
## 回看结论栏(11-23 填)
|
|
201
264
|
|
package/README.md
CHANGED
|
@@ -1,7 +1,123 @@
|
|
|
1
1
|
# dsh-steward — 智能管家(体检 + 授权执行)插件
|
|
2
2
|
|
|
3
|
-
**状态:v0.9.
|
|
3
|
+
**状态:v0.9.15 已安装并热挂载(体检 + 授权执行 + 对话入口 + 设置项)**
|
|
4
4
|
|
|
5
|
+
## v0.9.15(把「该有的职能」补齐:时间、设置、反馈、趋势、直达)
|
|
6
|
+
|
|
7
|
+
这一轮按职能设定逐条对照代码,补上八处「功能上没考虑到、但角色上应该有」的缺口。
|
|
8
|
+
顺序按影响排:先修真 bug 与死设置,再补反馈闭环,最后是趋势与直达。
|
|
9
|
+
|
|
10
|
+
### P0:两个「说了但没做」
|
|
11
|
+
|
|
12
|
+
- **设置页的「上次保存」差 8 小时**:`savedAt` 是 UTC ISO,旧代码用 `.replace("T"," ")` 切片
|
|
13
|
+
当成当地时间贴出来。静态判据只认单引号写法,双引号那次悄悄绕过去了——现在两种引号都查,
|
|
14
|
+
并且这一行改走 `localStamp()`。
|
|
15
|
+
- **`ui.autoScanMinutes` 是个死设置**:设置里能存 1–240 分钟、界面也回「已保存」,但客户端
|
|
16
|
+
把刷新阈值写死成 10 分钟。现在服务端把夹取后的值随报告下发,客户端按它算阈值;
|
|
17
|
+
「设置改了却没反应」正是验收表 A9 要抓的静默失效,不该长在自己家。
|
|
18
|
+
|
|
19
|
+
### P1:误报要能被纠正,管家才学得会
|
|
20
|
+
|
|
21
|
+
只有「已知晓(静音)」时,用户看到一条判错的结论,除了不理它没有别的办法。
|
|
22
|
+
新增 `feedback.json` 与面板按钮「这条判错了 / 撤销判错」,走与静音完全相同的
|
|
23
|
+
plan → 一次性指纹 → confirm → audit 链。标记后:证据仍然保留、不再计「有直达处置」、
|
|
24
|
+
重要性分数降 40 并写进 `scoreReasons`,所以它会排到后面而不是消失。
|
|
25
|
+
**这是可逆的判定,不是删除**。刻意不进语言层(brain.js / intent.js 不加这个 kind):
|
|
26
|
+
「把某条具体发现判错」用自然语言说不清是哪一条,按钮更不易误触。
|
|
27
|
+
|
|
28
|
+
### P2:趋势要会说话,直达不许造假
|
|
29
|
+
|
|
30
|
+
- 服务端新增 `summarizeTrend()`,用上一次报告算出 `trendVerdict`(好转 / 连续变差 / 平稳)
|
|
31
|
+
**和一句人话**,客户端只渲染——符合「服务端算好人话、客户端只渲染」。
|
|
32
|
+
审计趋势顶部显示结论,恢复或连续两次变差时右下角主动弹一条(不再只报坏不报好)。
|
|
33
|
+
- `ACTION_FOR` 里 `model-health → model-default` 指向一张并不存在的动作卡,是假直达:
|
|
34
|
+
删掉,并把 `backup-freshness → backup` 补上(备份新鲜度该能一步跳到备份卡)。
|
|
35
|
+
其余只有文字建议的 finding 不给按钮——宁可少给,也不给一个点了没反应的按钮。
|
|
36
|
+
- `wiring`(路由 / 工具到底注册上没有,v0.9.14 起只在 ping 里)随报告下发,
|
|
37
|
+
面板在生成时间旁显示「接口已挂上」;没挂上时给出可操作的提示。
|
|
38
|
+
|
|
39
|
+
### 边界
|
|
40
|
+
|
|
41
|
+
`feedback` 与 `ack` 都只写管家自己的数据目录,不动任何系统状态;口令仍绝不由 Agent 生成或代输。
|
|
42
|
+
|
|
43
|
+
## v0.9.14(冷启动竞态:路由与工具都注册了,只是没在最合适的那一刻)
|
|
44
|
+
|
|
45
|
+
### 症状和真正的原因不是同一件事
|
|
46
|
+
|
|
47
|
+
重启之后面板照旧报 `读取失败: 接口没有挂上(HTTP 404)`。v0.9.13 已经能把这句
|
|
48
|
+
话翻译成人话,但它把原因归给了「热重载」——而这一次是冷启动,热重载的解释
|
|
49
|
+
根本对不上。真正的原因更安静:
|
|
50
|
+
|
|
51
|
+
`apply()` 里原本是这样拿服务的:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const webServer = getService(ctx, 'webServer');
|
|
55
|
+
if (webServer && typeof webServer.register === 'function') { ... }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`getService` 只做 `ctx.get(key)` 或 `ctx[key]`,而 **`ctx.get` 在 Cordis 里只能拿到
|
|
59
|
+
那一刻已经 apply 完的服务**。冷启动时 `webServer` 与 `tools` 的 apply 都排在本插件
|
|
60
|
+
之后,于是那一次探测拿到 `null`,整个 `if` 被跳过:前缀路由没注册、两个
|
|
61
|
+
`dsh_steward_*` 工具也没注册。外面的 `try { ... } catch { /* ignore */ }` 是刻意的
|
|
62
|
+
fail-soft(注册失败不该拖垮 boot),代价是**失败完全无声**——面板照常从 manifest
|
|
63
|
+
画出来,然后每个请求都撞在「没有命名路由匹配 → 404」上。
|
|
64
|
+
|
|
65
|
+
所以这不是「路由写错了」,是「注册发生在错误的时刻」。热重载会是同一个症状,
|
|
66
|
+
但它们是两个不同的病因,v0.9.13 的诊断文案只认识其中一种。
|
|
67
|
+
|
|
68
|
+
### 改法:等它就绪,再注册
|
|
69
|
+
|
|
70
|
+
新增 `whenService(ctx, keys, fn)`:优先走 `ctx.inject(keys, cb)`,回调在服务真正
|
|
71
|
+
存在之后才执行;`:root` 上没有 `inject` 时退回旧的一次性探测(行为与从前一致,不会
|
|
72
|
+
更差)。三处注册(`systemPrompt` 段、前缀路由、tools)全部改走它。
|
|
73
|
+
|
|
74
|
+
- 用 `ctx.inject` 而不是 `export const inject = [...]`,是因为硬 inject 会让本插件
|
|
75
|
+
成为 **boot 依赖**——它的 apply 一旦抛错,整个 Web UI 起不来。这个插件的第一原则
|
|
76
|
+
是「不许让面板成为新的崩溃源」,所以宁可延迟注册,也不升级成硬依赖。
|
|
77
|
+
- `ctx.effect` 仍然包在回调里,所以热重载时 dispose 依旧会被正确回收。
|
|
78
|
+
|
|
79
|
+
### 失败必须能看见
|
|
80
|
+
|
|
81
|
+
注册结果记在 `wiring.route` 里,`ping` 的返回体多一个字段:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
{"ok":true,...,"wiring":{"route":"registered"}}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
下次再出现「面板画出来了但每个请求都 404」,只需要看一眼 ping:`registered` 说明
|
|
88
|
+
路由在,问题在别处;`pending` 或 `failed: ...` 直接指到病因。**静默的 fail-soft 是
|
|
89
|
+
这次真正的坑**——比注册失败本身更难查,因为它连一条可搜的日志都不留。
|
|
90
|
+
|
|
91
|
+
### 这一版钉住的判据
|
|
92
|
+
|
|
93
|
+
`smoke.mjs` 新增五条静态判据(291 条断言):必须用 `ctx.inject` 延迟注册;三处注册
|
|
94
|
+
全都走 `whenService`;**旧的裸 `try { const webServer = getService(...)` 写法必须彻底
|
|
95
|
+
消失**;`wiring.route` 要记录成功与失败两种结果;`ping` 要把它带出来。反向验证过:
|
|
96
|
+
把路由那段改回一次性探测,立刻 `289 passed, 2 failed`。
|
|
97
|
+
|
|
98
|
+
## v0.9.13(路由没挂上时,别只说 HTTP 404)
|
|
99
|
+
|
|
100
|
+
改插件源码会让宿主热重载:旧 fiber 被销毁,新 fiber 重新跑一遍 `apply()`。
|
|
101
|
+
真机踩到的形态是——重载过后 `apply()` 里那段 `webServer.register({ kind: 'prefix', path: ROUTE_PREFIX })`
|
|
102
|
+
没有接上,面板打开就只剩一行红字 `读取失败: HTTP 404`:bundle 是 enabled + installed,
|
|
103
|
+
源码 `node --check` 全过,`import()` 也正常,只有路由不见了。
|
|
104
|
+
|
|
105
|
+
`HTTP 404` 这句话的问题不是不准,是**没法行动**:它和「服务端主动回了个 404」长得一模一样,
|
|
106
|
+
而这两种情况的处置完全不同。所以这一版:
|
|
107
|
+
|
|
108
|
+
1. **认得出来** —— 自己这条路由上的 404 单独成句:说清「面板加载了,但 /api/dsh-steward/ 没注册」,
|
|
109
|
+
并点名最常见的原因(热重载后旧路由被销毁、新的没接上)。
|
|
110
|
+
2. **说得出怎么办** —— 提示「设置里停用再启用一次」或「直接重启 DSH Web」,两者都真的能修。
|
|
111
|
+
3. **重试一次就够** —— 热重载有一个几百毫秒的窗口,这时路由确实不在;重试一次不会把
|
|
112
|
+
真正的故障藏起来,也不会让用户对着一个会自动好的状态点按钮。
|
|
113
|
+
4. **别的错误不许挪用这套话** —— 非 404 的失败仍然原样报服务端给的原因,
|
|
114
|
+
否则「热重载」就成了一个什么都往里装的筐。
|
|
115
|
+
|
|
116
|
+
五条断言全部是**静态判据**(读 `lib/client.js` 自己的源码),并且反向验证过:把
|
|
117
|
+
`response.status === 404 && tries === 0` 改成 `if (false)`,`285 passed / 1 failed`。
|
|
118
|
+
|
|
119
|
+
> 顺带记一条运维经验:这个状态下**只有重启/重挂**能修,改代码不会修好它。
|
|
120
|
+
> 而 `plugin_manager set_bundle enabled:false` → `enabled:true` 就是重挂。
|
|
5
121
|
## v0.9.12(配色:从「自造一套蓝」改成 baseline 的六个 accent)
|
|
6
122
|
|
|
7
123
|
面板里原本散着 12 处私有颜色:`rgba(90,159,212,.14)` 这种自造的蓝、`#e05555` 这种自造的红、
|
|
@@ -241,7 +357,7 @@ v0.7 给这个输入框加了「模型理解」:可选的第二通话方式,
|
|
|
241
357
|
- `lib/brain.js`:v0.7 模型理解层。把一句话交给宿主 `llm` 服务,把模型输出归一成与 `intent.js` **完全同形状**的意图;JSON 抽取容错(容忍围栏与前后废话),各字段走白名单校验,任何不合规都降级为 clarify。**只翻译,不执行。**
|
|
242
358
|
- `lib/index.js`:host(路由 + `dsh_steward_report` / `dsh_steward_act` 两个工具 + 事件订阅 + boot 后自动体检 + systemPrompt 注入)。
|
|
243
359
|
- `lib/client.js`:client(`__ModuleLoader__.load` + 三 Tab 面板 + 动作卡 + 侧栏/右侧栏入口,全内联样式)。
|
|
244
|
-
- `smoke.mjs`:本地验证(不启动宿主),
|
|
360
|
+
- `smoke.mjs`:本地验证(不启动宿主),292 项断言(含打包自检、时间戳、配色与「延迟注册」四类静态规约判据)。
|
|
245
361
|
|
|
246
362
|
## 本地验证(不装、不重启)
|
|
247
363
|
```
|
package/lib/actions.js
CHANGED
|
@@ -104,6 +104,16 @@ async function readAcked(deps) {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** v0.9.15: 'this judgement was wrong' is distinct from 'I know, be quiet'.
|
|
108
|
+
* Entries are permanent until explicitly undone; expiry would silently
|
|
109
|
+
* re-arm a finding the user already told us was a false positive. */
|
|
110
|
+
async function readFeedback(deps) {
|
|
111
|
+
let list = [];
|
|
112
|
+
try { list = (await deps.io.readJson(deps.feedbackFile)) || []; } catch { list = []; }
|
|
113
|
+
if (!Array.isArray(list)) return [];
|
|
114
|
+
return list.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
/** Default mute duration per finding id — a mutable condition gets a shorter
|
|
108
118
|
* leash than a structural one. These are FALLBACKS only: v0.8.0 起真正的值来自
|
|
109
119
|
* 用户设置(settings.ttl.<id> → settings.ttl.default),这里只作为 settings.js
|
|
@@ -135,7 +145,7 @@ function currentSettings(deps) {
|
|
|
135
145
|
* - readBytes(file): Buffer|string — used ONLY to verify a freshly written
|
|
136
146
|
* DCA1 container in-request; never persisted, never logged.
|
|
137
147
|
* - now(): () => Date
|
|
138
|
-
* - auditFile / trendFile / knowledgeFile / ackedFile: paths under plugin data dir.
|
|
148
|
+
* - auditFile / trendFile / knowledgeFile / ackedFile / feedbackFile: paths under plugin data dir.
|
|
139
149
|
*/
|
|
140
150
|
export function createActionHub(deps) {
|
|
141
151
|
const pending = new Map(); // hash -> { kind, plan, createdAt }
|
|
@@ -335,6 +345,29 @@ export function createActionHub(deps) {
|
|
|
335
345
|
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
336
346
|
return { ok: true, planHash: hash, plan: p };
|
|
337
347
|
}
|
|
348
|
+
if (kind === 'feedback' || kind === 'unfeedback') {
|
|
349
|
+
const report = typeof deps.getReport === 'function' ? deps.getReport() : null;
|
|
350
|
+
const id = typeof params.id === 'string' ? params.id.trim() : '';
|
|
351
|
+
if (!report || !Array.isArray(report.findings)) return { ok: false, error: '还没有体检报告——先做一次体检。' };
|
|
352
|
+
if (!id) return { ok: false, error: '需要 finding id(报告条目的编号)。' };
|
|
353
|
+
const f = report.findings.find((x) => x.id === id);
|
|
354
|
+
if (!f) return { ok: false, error: `报告里没有编号 "${id}" 的条目。` };
|
|
355
|
+
const list = await readFeedback(deps);
|
|
356
|
+
const marked = list.some((a) => a.id === id);
|
|
357
|
+
if (kind === 'feedback' && marked) return { ok: false, error: `「${f.title}」已经标记为判错了。` };
|
|
358
|
+
if (kind === 'unfeedback' && !marked) return { ok: false, error: `「${f.title}」没有被标记为判错。` };
|
|
359
|
+
const p = {
|
|
360
|
+
kind, findingId: id, title: f.title,
|
|
361
|
+
human: kind === 'feedback'
|
|
362
|
+
? [`把「${f.title}」标记为这条判错了。`,
|
|
363
|
+
'效果:从下次体检起这条不再计直达处置、重要性分数降 40,但证据仍然保留,随时可撤销。',
|
|
364
|
+
'这只是告诉管家「这次的判断不准」,不会改动任何系统状态。']
|
|
365
|
+
: [`撤销「${f.title}」的判错标记,恢复它的正常评分。`],
|
|
366
|
+
};
|
|
367
|
+
const hash = hashPlan(kind, p);
|
|
368
|
+
remember(hash, { kind, plan: p, createdAt: deps.now().toISOString() });
|
|
369
|
+
return { ok: true, planHash: hash, plan: p };
|
|
370
|
+
}
|
|
338
371
|
if (kind === 'model-default') {
|
|
339
372
|
// 只改「默认模型是哪一个」这一项选择,不碰任何插件的配置。
|
|
340
373
|
// 这是三个模型类动作里唯一会写宿主状态的,所以它必须有 plan 与指纹。
|
|
@@ -487,6 +520,21 @@ export function createActionHub(deps) {
|
|
|
487
520
|
'恢复:该条目上的「取消知晓」,或 Agent 工具 kind=unack。']
|
|
488
521
|
: [`「${entry.plan.title}」已恢复计入风险计数。`],
|
|
489
522
|
};
|
|
523
|
+
} else if (entry.kind === 'feedback' || entry.kind === 'unfeedback') {
|
|
524
|
+
let list = await readFeedback(deps);
|
|
525
|
+
const id = entry.plan.findingId;
|
|
526
|
+
if (entry.kind === 'feedback') {
|
|
527
|
+
if (!list.some((a) => a.id === id)) list.push({ id, title: entry.plan.title, at: deps.now().toISOString() });
|
|
528
|
+
} else {
|
|
529
|
+
list = list.filter((a) => a.id !== id);
|
|
530
|
+
}
|
|
531
|
+
await deps.io.writeJson(deps.feedbackFile, list);
|
|
532
|
+
result = {
|
|
533
|
+
ok: true, findingId: id,
|
|
534
|
+
human: entry.kind === 'feedback'
|
|
535
|
+
? [`已把「${entry.plan.title}」标记为判错——下次体检起它会排在后面,但证据仍在。`, '撤销:该条目上的「撤销判错」。']
|
|
536
|
+
: [`「${entry.plan.title}」的判错标记已撤销,恢复正常评分。`],
|
|
537
|
+
};
|
|
490
538
|
} else if (entry.kind === 'model-default') {
|
|
491
539
|
const sel = typeof deps.getDefaultModel === 'function' ? deps.getDefaultModel() : null;
|
|
492
540
|
if (!sel || typeof sel.saveSelection !== 'function') {
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-steward · client entry — v0.9.
|
|
2
|
+
* dsh-steward · client entry — v0.9.15 (model-mode conversational entry + L1 briefing;
|
|
3
3
|
* ChatBar rebuilt as a mature chat composer: one rounded input bar, mode chip on the
|
|
4
4
|
* left, round send button on the right, settings panel below the bar;
|
|
5
5
|
* v0.7.5 collapse: a collapsed group lists EVERY peer as a two-line summary row
|
|
@@ -99,10 +99,10 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
// finding → 动作直达映射(点按钮 = 切到动作中心并展开该动作)
|
|
100
100
|
const ACTION_FOR = {
|
|
101
101
|
'data-at-risk': 'backup',
|
|
102
|
+
'backup-freshness': 'backup',
|
|
102
103
|
'boot-crash-latest': 'disable',
|
|
103
104
|
'disk-advice': 'clean',
|
|
104
105
|
'knowledge': 'knowledge',
|
|
105
|
-
'model-health': 'model-default',
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
// v0.9.8: every request used to be able to hang forever. The server is allowed
|
|
@@ -119,7 +119,8 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
function timeoutFor(action) {
|
|
120
120
|
return SLOW_ACTIONS[action] || DEFAULT_TIMEOUT_MS;
|
|
121
121
|
}
|
|
122
|
-
async function call(action, extra) {
|
|
122
|
+
async function call(action, extra, attempt) {
|
|
123
|
+
const tries = typeof attempt === 'number' ? attempt : 0;
|
|
123
124
|
const name = action || 'report';
|
|
124
125
|
const budget = timeoutFor(name);
|
|
125
126
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
@@ -145,7 +146,24 @@ window.__ModuleLoader__.load({
|
|
|
145
146
|
if (timer) clearTimeout(timer);
|
|
146
147
|
}
|
|
147
148
|
let data;
|
|
148
|
-
try { data = await response.json(); } catch {
|
|
149
|
+
try { data = await response.json(); } catch { data = null; }
|
|
150
|
+
// v0.9.13: a 404 on our OWN route is a different failure from a 404 the
|
|
151
|
+
// server sent on purpose. It means this plugin's fiber is loaded (the
|
|
152
|
+
// panel rendered) but its route is not mounted. v0.9.14: there are TWO
|
|
153
|
+
// causes and the earlier wording only named the first — a host hot-reload
|
|
154
|
+
// disposes the old fiber, AND a cold boot can have webServer apply after
|
|
155
|
+
// this plugin, which is what the deferred registration now handles.
|
|
156
|
+
if (response.status === 404 && tries === 0) {
|
|
157
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
158
|
+
return call(action, extra, tries + 1);
|
|
159
|
+
}
|
|
160
|
+
if (response.status === 404) {
|
|
161
|
+
throw new Error('接口没有挂上(HTTP 404)——面板加载了,但 /api/dsh-steward/ 这段路由没注册。'
|
|
162
|
+
+ '两种常见原因:宿主热重载(改了插件文件,旧路由被销毁、新的没接上),'
|
|
163
|
+
+ '或冷启动时 webServer 比插件晚就绪。'
|
|
164
|
+
+ '在设置里把这个插件停用再启用一次通常就能恢复;也可以重启 DSH Web。'
|
|
165
|
+
+ '恢复后 ping 会带 wiring.route,值是 registered 就说明路由真的挂上了。');
|
|
166
|
+
}
|
|
149
167
|
if (!response.ok || !data || data.ok !== true) {
|
|
150
168
|
throw new Error((data && data.error) || 'HTTP ' + response.status);
|
|
151
169
|
}
|
|
@@ -247,6 +265,7 @@ window.__ModuleLoader__.load({
|
|
|
247
265
|
},
|
|
248
266
|
h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } },
|
|
249
267
|
f.muted ? h(Badge, { color: C.faint }, '已静音') : h(Badge, { color: sev.color }, sev.label),
|
|
268
|
+
f.falsePositive ? h(Badge, { color: C.faint }, '已判错') : null,
|
|
250
269
|
typeof f.score === 'number' ? h('span', {
|
|
251
270
|
title: (f.scoreReasons || []).join(';') || '评分构成见体检报告',
|
|
252
271
|
style: { fontSize: 11, color: C.faint, marginLeft: 6, cursor: 'help' },
|
|
@@ -266,6 +285,8 @@ window.__ModuleLoader__.load({
|
|
|
266
285
|
props.onGo ? h(Btn, { onClick: props.onGo, primary: true, tone: C.text }, props.goLabel || '去处置 →') : null,
|
|
267
286
|
props.onAck ? h(TinyBtn, { onClick: props.onAck }, '已知晓(静音此项)') : null,
|
|
268
287
|
props.onUnack ? h(TinyBtn, { onClick: props.onUnack, tone: C.info }, '恢复显示此项') : null,
|
|
288
|
+
props.onFalsePositive ? h(TinyBtn, { onClick: props.onFalsePositive, tone: C.warning }, '这条判错了') : null,
|
|
289
|
+
props.onUnfalsePositive ? h(TinyBtn, { onClick: props.onUnfalsePositive, tone: C.info }, '撤销判错') : null,
|
|
269
290
|
),
|
|
270
291
|
);
|
|
271
292
|
}
|
|
@@ -393,16 +414,18 @@ window.__ModuleLoader__.load({
|
|
|
393
414
|
return arr;
|
|
394
415
|
};
|
|
395
416
|
// 台账行必须说人话:内部 kind/error 码是机器字段,直接 dump 给用户等于没做呈现层。
|
|
396
|
-
const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现' };
|
|
417
|
+
const KIND_ZH = { clean: '磁盘清理', backup: '加密备份', disable: '停用插件', enable: '启用插件', knowledge: '沉淀知识', ack: '静音一项发现', unack: '恢复一项发现', feedback: '标记一条判错', unfeedback: '撤销判错标记' };
|
|
397
418
|
const ERR_ZH = {
|
|
398
419
|
'unknown-or-expired-plan': '计划已过期或指纹不对——指纹是一次性的,请重新「预览计划」',
|
|
399
420
|
'confirm-missing': '缺少授权确认——执行必须带 confirm:true',
|
|
400
421
|
};
|
|
401
422
|
const countsZh = (c) => (c ? ['严重 ' + (c.critical || 0), '留意 ' + (c.warning || 0), '提示 ' + (c.info || 0)].join(' · ') + ((c.muted || 0) > 0 ? ' · 静音 ' + c.muted : '') : '');
|
|
423
|
+
const tv = data && data.trendVerdict;
|
|
402
424
|
return h('div', {},
|
|
403
425
|
h('div', { style: { fontSize: 12, color: C.sub, lineHeight: 1.7, margin: '2px 0 10px', padding: '8px 10px', borderLeft: '2px solid ' + C.info, background: C.fill, borderRadius: 6 } },
|
|
404
426
|
'这里是管家的操作台账:它经你授权动过系统的每一次记录都写在这里,包括被拒绝的尝试。', h('br'),
|
|
405
427
|
'用途:核对「上次到底清了什么、什么时候、成没成」,以及看体检结论随时间是变好还是变坏。'),
|
|
428
|
+
tv && tv.text ? h('div', { style: { fontSize: 12, marginBottom: 10, padding: '7px 10px', borderRadius: 6, borderLeft: '2px solid ' + (tv.state === 'recovered' ? C.ok : tv.state === 'worsening' ? C.warning : C.info), background: C.fill, color: C.text } }, '趋势结论 · ' + tv.text) : null,
|
|
406
429
|
h('div', { style: { marginBottom: 10, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
|
407
430
|
h(Btn, { onClick: load, disabled: loading }, loading ? '读取中…' : '刷新台账'),
|
|
408
431
|
// 不用原生 <select>:Windows 下拉弹层由系统绘制,无视宿主主题(白底浅字不可读)。分段按钮全部内联样式,跟随面板配色。
|
|
@@ -445,7 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
445
468
|
}
|
|
446
469
|
|
|
447
470
|
// 模块级缓存:Panel 切走即卸载;报告与插件变更留在内存(刷新清空属预期)。
|
|
448
|
-
const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null };
|
|
471
|
+
const cache = { report: null, error: '', hidden: {}, auditSort: 'newest', mode: 'rules', model: null, autoScanMinutes: 10, wiring: null };
|
|
449
472
|
// v0.8.4:当前生效的入口重挂函数,由 apply() 填、由设置页调用。
|
|
450
473
|
// 存在它就不需要「改完要 Ctrl+R」——那前提在桌面版里根本不成立。
|
|
451
474
|
// ---------- 右下角临时弹窗(v0.9.1) ----------
|
|
@@ -506,6 +529,18 @@ window.__ModuleLoader__.load({
|
|
|
506
529
|
toastedIds.add(f.id);
|
|
507
530
|
pushToast(toastOf(f));
|
|
508
531
|
}
|
|
532
|
+
// v0.9.15: 好转与连续恶化也主动说。结论由服务端算好人话,客户端只渲染。
|
|
533
|
+
const tv = report && report.trendVerdict;
|
|
534
|
+
if (tv && (tv.state === 'recovered' || (tv.state === 'worsening' && tv.worseningStreak >= 2))) {
|
|
535
|
+
const key = 'trend-' + tv.state + '-' + (report.generatedAt || '');
|
|
536
|
+
if (!toastedIds.has(key)) {
|
|
537
|
+
toastedIds.add(key);
|
|
538
|
+
pushToast({ kind: 'trend', findingId: null, actionKey: null,
|
|
539
|
+
severity: tv.state === 'recovered' ? 'ok' : 'info',
|
|
540
|
+
title: tv.state === 'recovered' ? '状况好转了' : '连续几次体检都在变差',
|
|
541
|
+
body: tv.text || '' });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
509
544
|
}
|
|
510
545
|
|
|
511
546
|
// 弹窗住在 shell.overlay 里,不在面板的树里 —— 点击「去看看」需要一条通道
|
|
@@ -984,7 +1019,7 @@ window.__ModuleLoader__.load({
|
|
|
984
1019
|
const gs = Array.isArray(res.groups) ? res.groups : [];
|
|
985
1020
|
setGroups(gs);
|
|
986
1021
|
setDraft(flatOf(gs));
|
|
987
|
-
setSaved(res.savedAt ? "上次保存:" +
|
|
1022
|
+
setSaved(res.savedAt ? "上次保存:" + localStamp(res.savedAt) + "(本地时间)" : "还没改过(全部是推荐默认值)");
|
|
988
1023
|
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
989
1024
|
finally { setLoading(false); }
|
|
990
1025
|
}, []);
|
|
@@ -1088,7 +1123,7 @@ window.__ModuleLoader__.load({
|
|
|
1088
1123
|
const timer = setTimeout(() => { setLeft((v) => v - 1); }, 1000);
|
|
1089
1124
|
return () => clearTimeout(timer);
|
|
1090
1125
|
}, [left, paused]);
|
|
1091
|
-
const tone = t.severity === 'critical' ? C.critical : C.warning;
|
|
1126
|
+
const tone = t.severity === 'critical' ? C.critical : t.severity === 'ok' ? C.ok : t.severity === 'info' ? C.info : C.warning;
|
|
1092
1127
|
return h('div', {
|
|
1093
1128
|
// 鼠标移上去暂停计时:正在读的时候消失,比不弹更烦人。
|
|
1094
1129
|
onMouseEnter: () => setPaused(true),
|
|
@@ -1101,7 +1136,7 @@ window.__ModuleLoader__.load({
|
|
|
1101
1136
|
},
|
|
1102
1137
|
},
|
|
1103
1138
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 } },
|
|
1104
|
-
h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : '留意'),
|
|
1139
|
+
h('span', { style: { fontSize: 10.5, color: tone, fontWeight: 600, flex: '0 0 auto' } }, t.severity === 'critical' ? '严重' : t.severity === 'ok' ? '好转' : t.severity === 'info' ? '提醒' : '留意'),
|
|
1105
1140
|
h('span', { style: { fontSize: 13, fontWeight: 600, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t.title),
|
|
1106
1141
|
h('button', {
|
|
1107
1142
|
onClick: props.onDismiss, title: '关闭',
|
|
@@ -1110,15 +1145,15 @@ window.__ModuleLoader__.load({
|
|
|
1110
1145
|
),
|
|
1111
1146
|
t.body ? h('div', { style: { fontSize: 11.5, color: C.sub, marginBottom: 7, whiteSpace: 'pre-wrap' } }, t.body) : null,
|
|
1112
1147
|
h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
1113
|
-
h('button', {
|
|
1148
|
+
t.actionKey ? h('button', {
|
|
1114
1149
|
onClick: () => { props.onOpen(); props.onDismiss(); },
|
|
1115
1150
|
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + tone, background: 'transparent', color: tone },
|
|
1116
|
-
}, '去看看'),
|
|
1117
|
-
h('button', {
|
|
1151
|
+
}, '去看看') : null,
|
|
1152
|
+
t.findingId ? h('button', {
|
|
1118
1153
|
onClick: () => { props.onAck(); props.onDismiss(); },
|
|
1119
1154
|
title: '静音这条,直到有效期结束',
|
|
1120
1155
|
style: { cursor: 'pointer', fontSize: 11.5, padding: '3px 10px', borderRadius: 6, border: '1px solid ' + C.line, background: 'transparent', color: C.sub },
|
|
1121
|
-
}, '已知晓'),
|
|
1156
|
+
}, '已知晓') : null,
|
|
1122
1157
|
h('span', { style: { flex: '1 1 auto' } }),
|
|
1123
1158
|
h('span', { style: { fontSize: 10, color: C.faint, flex: '0 0 auto' } }, paused ? '暂停' : left + 's'),
|
|
1124
1159
|
),
|
|
@@ -1157,6 +1192,8 @@ window.__ModuleLoader__.load({
|
|
|
1157
1192
|
try {
|
|
1158
1193
|
const data = await call('report');
|
|
1159
1194
|
if (!alive) return;
|
|
1195
|
+
if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
|
|
1196
|
+
if (data && data.wiring) cache.wiring = data.wiring;
|
|
1160
1197
|
if (data && data.report) { cache.report = data.report; evaluateToasts(data.report); }
|
|
1161
1198
|
} catch { /* 采集失败不弹任何东西——宁可不提醒,也不误报 */ }
|
|
1162
1199
|
})();
|
|
@@ -1200,6 +1237,8 @@ window.__ModuleLoader__.load({
|
|
|
1200
1237
|
setLoading(true); setError('');
|
|
1201
1238
|
try {
|
|
1202
1239
|
const data = await call('report');
|
|
1240
|
+
if (data && typeof data.autoScanMinutes === 'number') cache.autoScanMinutes = data.autoScanMinutes;
|
|
1241
|
+
if (data && data.wiring) cache.wiring = data.wiring;
|
|
1203
1242
|
try { publishReport(data && data.report); } catch { /* noop */ }
|
|
1204
1243
|
cache.report = data.report || null;
|
|
1205
1244
|
cache.error = '';
|
|
@@ -1215,12 +1254,13 @@ window.__ModuleLoader__.load({
|
|
|
1215
1254
|
try { setBundles(await call('bundles')); } catch { setBundles(null); }
|
|
1216
1255
|
}, []);
|
|
1217
1256
|
|
|
1218
|
-
//
|
|
1257
|
+
// 回到面板不再要求手点:无缓存→挂载即自动体检一次;缓存超过设置的分钟数→后台刷新。
|
|
1219
1258
|
// 先例:disk-cleaner 面板同样挂载即扫,同一 GUI 正常运行——二分定案证明卡死元凶不是自动扫描。
|
|
1220
1259
|
useEffect(() => {
|
|
1221
1260
|
if (!cache.report) { void scan(); return; }
|
|
1222
1261
|
const age = Date.now() - Date.parse(String(cache.report.generatedAt || ''));
|
|
1223
|
-
|
|
1262
|
+
const maxAge = (Number.isFinite(cache.autoScanMinutes) ? cache.autoScanMinutes : 10) * 60 * 1000;
|
|
1263
|
+
if (Number.isFinite(age) && age > maxAge) void scan();
|
|
1224
1264
|
}, [scan]);
|
|
1225
1265
|
|
|
1226
1266
|
const crashFinding = report && Array.isArray(report.findings)
|
|
@@ -1252,6 +1292,16 @@ window.__ModuleLoader__.load({
|
|
|
1252
1292
|
await scan();
|
|
1253
1293
|
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1254
1294
|
}, [scan]);
|
|
1295
|
+
const falsePositiveIt = useCallback(async (finding, undo) => {
|
|
1296
|
+
try {
|
|
1297
|
+
const p = await call('plan', { kind: undo ? 'unfeedback' : 'feedback', id: finding.id });
|
|
1298
|
+
if (p.ok === false) throw new Error(p.error || 'plan 失败');
|
|
1299
|
+
await call('execute', { planHash: p.planHash, confirm: true });
|
|
1300
|
+
await scan();
|
|
1301
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)); }
|
|
1302
|
+
}, [scan]);
|
|
1303
|
+
const falsePositiveRef = useRef(falsePositiveIt);
|
|
1304
|
+
falsePositiveRef.current = falsePositiveIt;
|
|
1255
1305
|
|
|
1256
1306
|
// 把面板内的能力交给 overlay 里的弹窗宿主。
|
|
1257
1307
|
React.useEffect(() => {
|
|
@@ -1298,8 +1348,10 @@ window.__ModuleLoader__.load({
|
|
|
1298
1348
|
finding: f,
|
|
1299
1349
|
onAck: !f.muted ? () => ackIt(f) : null,
|
|
1300
1350
|
onUnack: f.muted ? () => unackIt(f) : null,
|
|
1351
|
+
onFalsePositive: !f.falsePositive ? () => falsePositiveIt(f, false) : null,
|
|
1352
|
+
onUnfalsePositive: f.falsePositive ? () => falsePositiveIt(f, true) : null,
|
|
1301
1353
|
onGo: ACTION_FOR[f.id] && !f.muted && f.severity !== 'info' ? () => jumpToAction(ACTION_FOR[f.id]) : null,
|
|
1302
|
-
goLabel: f.id === 'data-at-risk' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
|
|
1354
|
+
goLabel: f.id === 'data-at-risk' || f.id === 'backup-freshness' ? '去备份 →' : f.id === 'boot-crash-latest' ? '去崩溃处置 →' : f.id === 'disk-advice' ? '去看可清理 →' : null,
|
|
1303
1355
|
});
|
|
1304
1356
|
const groupBlock = (label, color, list) => {
|
|
1305
1357
|
if (!list.length) return null;
|
|
@@ -1405,6 +1457,10 @@ window.__ModuleLoader__.load({
|
|
|
1405
1457
|
h('div', {}, '动作执行全部写入 audit.jsonl,可在「审计趋势」回看。'),
|
|
1406
1458
|
h('div', { style: { marginTop: 4 } }, '依赖清单与降级行为、设计取舍见插件目录 README(dsh-steward/README.md)。')) : null,
|
|
1407
1459
|
report ? h('div', { style: { fontSize: 10.5, color: C.faint, marginTop: 8 } }, '生成时间 · ' + localStamp(report.generatedAt) + (agoText(report.generatedAt) ? '(' + agoText(report.generatedAt) + ')' : '')) : null,
|
|
1460
|
+
report && cache.wiring ? h('div', { style: { fontSize: 10.5, marginTop: 4, color: cache.wiring.route === 'registered' ? C.faint : C.critical } },
|
|
1461
|
+
cache.wiring.route === 'registered'
|
|
1462
|
+
? '接口已挂上 · 路由与工具随服务就绪后注册'
|
|
1463
|
+
: '接口未挂上(' + String(cache.wiring.route) + ')——停用再启用本插件,或重启 DSH Web') : null,
|
|
1408
1464
|
) : null,
|
|
1409
1465
|
|
|
1410
1466
|
tab === 'actions' ? h('div', { style: { marginTop: 12 } },
|
package/lib/index.js
CHANGED
|
@@ -65,6 +65,48 @@ function getService(ctx, key) {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// What the deferred wiring actually achieved. Reported by ping so "the panel
|
|
69
|
+
// draws but every request 404s" stops being indistinguishable from "fine".
|
|
70
|
+
const wiring = { route: 'pending', via: {} };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* v0.9.14: run `fn` once the named services are live.
|
|
74
|
+
*
|
|
75
|
+
* getService() only sees services that are ALREADY active at the moment of the
|
|
76
|
+
* call. On a cold boot `webServer` and `tools` activate after this plugin, so a
|
|
77
|
+
* one-shot lookup returns null and every registration is skipped — silently,
|
|
78
|
+
* because each block is wrapped in a fail-soft try/catch. The symptom is exactly
|
|
79
|
+
* what was reported: the panel draws itself (its client bundle comes from the
|
|
80
|
+
* manifest) while the route and both tools were never registered, so each
|
|
81
|
+
* request answered 404 and every dsh_steward_* tool was absent.
|
|
82
|
+
*
|
|
83
|
+
* ctx.inject() is the documented optional-access path: it defers the callback
|
|
84
|
+
* until the services exist. Unlike a hard `export const inject` it does not make
|
|
85
|
+
* this bundle a boot dependency.
|
|
86
|
+
*/
|
|
87
|
+
function whenService(ctx, keys, fn) {
|
|
88
|
+
let injected = false;
|
|
89
|
+
try {
|
|
90
|
+
if (typeof ctx.inject === 'function') {
|
|
91
|
+
ctx.inject(keys, (scoped) => {
|
|
92
|
+
wiring.via[keys.join(',')] = 'inject';
|
|
93
|
+
try { fn(scoped || ctx); } catch (e) {
|
|
94
|
+
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] deferred setup failed (' + keys.join(',') + '): ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
injected = true;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] ctx.inject unavailable: ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
101
|
+
}
|
|
102
|
+
if (injected) return true;
|
|
103
|
+
// Fallback for a ctx without inject(): the old best-effort one-shot lookup.
|
|
104
|
+
try {
|
|
105
|
+
if (getService(ctx, keys[0])) { wiring.via[keys.join(',')] = 'one-shot'; fn(ctx); return true; }
|
|
106
|
+
} catch { /* nothing live yet — stays inert, as before */ }
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
68
110
|
function sendJson(res, status, value) {
|
|
69
111
|
try {
|
|
70
112
|
const body = Buffer.from(JSON.stringify(value));
|
|
@@ -170,6 +212,7 @@ function renderReportText(report) {
|
|
|
170
212
|
lines.push(report.summary.headline);
|
|
171
213
|
lines.push('');
|
|
172
214
|
lines.push(`生成时间: ${report.generatedAt}(UTC) | 状态: ${report.status} | 计数: critical=${report.summary.counts.critical} warning=${report.summary.counts.warning} info=${report.summary.counts.info}`);
|
|
215
|
+
if (report.trendVerdict && report.trendVerdict.text) lines.push(`趋势: ${report.trendVerdict.text}`);
|
|
173
216
|
lines.push('');
|
|
174
217
|
for (const f of report.findings) {
|
|
175
218
|
lines.push(`## [${f.severity.toUpperCase()}] ${f.title}`);
|
|
@@ -228,6 +271,7 @@ export function apply(ctx) {
|
|
|
228
271
|
configBaseline: path.join(dataDir(), 'config-baseline.json'),
|
|
229
272
|
settings: path.join(dataDir(), 'settings.json'),
|
|
230
273
|
modelProbes: path.join(dataDir(), 'model-probes.json'),
|
|
274
|
+
feedback: path.join(dataDir(), 'feedback.json'),
|
|
231
275
|
};
|
|
232
276
|
// v0.8.0 设置:内存缓存 + 落盘。读不到/坏了 → 全默认(旧行为),绝不拦住体检。
|
|
233
277
|
let settingsCache = null;
|
|
@@ -333,6 +377,7 @@ export function apply(ctx) {
|
|
|
333
377
|
trendFile: files.trend,
|
|
334
378
|
knowledgeFile: files.knowledge,
|
|
335
379
|
ackedFile: files.acked,
|
|
380
|
+
feedbackFile: files.feedback,
|
|
336
381
|
});
|
|
337
382
|
|
|
338
383
|
// ---------- v0.9.1 模型健康 ----------
|
|
@@ -451,7 +496,7 @@ export function apply(ctx) {
|
|
|
451
496
|
}
|
|
452
497
|
|
|
453
498
|
async function run(action, payload) {
|
|
454
|
-
if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true };
|
|
499
|
+
if (action === 'ping') return { ok: true, plugin: name, version: pkgVersion(), readOnly: false, hub: true, wiring: { ...wiring } };
|
|
455
500
|
// v0.8.0 settings: 只读设置页需要的一切(值 + 范围 + 为什么),以及一次写入。
|
|
456
501
|
if (action === 'settings') {
|
|
457
502
|
await loadSettings();
|
|
@@ -466,6 +511,8 @@ export function apply(ctx) {
|
|
|
466
511
|
if (action === 'report') {
|
|
467
512
|
let acked = [];
|
|
468
513
|
try { acked = (await io.readJson(files.acked)) || []; } catch { acked = []; }
|
|
514
|
+
let feedback = [];
|
|
515
|
+
try { feedback = (await io.readJson(files.feedback)) || []; } catch { feedback = []; }
|
|
469
516
|
const knowledge = await loadKnowledge();
|
|
470
517
|
// ④ v0.7.6 config drift: compare against the baseline recorded on the
|
|
471
518
|
// previous scan, then renew it. The baseline is ours (data dir), never the
|
|
@@ -486,13 +533,13 @@ export function apply(ctx) {
|
|
|
486
533
|
if (saved && saved.report && typeof saved.report === 'object') previous = saved.report;
|
|
487
534
|
} catch { previous = null; }
|
|
488
535
|
const modelHealth = await modelHealthForReport();
|
|
489
|
-
lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
|
|
536
|
+
lastReport = collectReport({ pluginChanges: changes.snapshot(), acked: Array.isArray(acked) ? acked : [], feedback: Array.isArray(feedback) ? feedback : [], knowledge, configBaseline, previous, settings: currentSettings(), modelHealth });
|
|
490
537
|
// Persist best-effort; a failed persist never fails the report itself.
|
|
491
538
|
io.writeJson(files.lastReport, { report: lastReport }).catch(() => {});
|
|
492
539
|
if (Array.isArray(lastReport.config) && lastReport.config.length > 0) {
|
|
493
540
|
io.writeJson(files.configBaseline, { at: lastReport.generatedAt, entries: lastReport.config }).catch(() => {});
|
|
494
541
|
}
|
|
495
|
-
return { report: lastReport, text: renderReportText(lastReport) };
|
|
542
|
+
return { report: lastReport, text: renderReportText(lastReport), autoScanMinutes: settingValue(currentSettings(), 'ui.autoScanMinutes'), wiring: { ...wiring } };
|
|
496
543
|
}
|
|
497
544
|
if (action === 'plan') return await hub.plan(payload.kind, payload);
|
|
498
545
|
if (action === 'execute') {
|
|
@@ -614,7 +661,7 @@ export function apply(ctx) {
|
|
|
614
661
|
} catch { auditLines = []; }
|
|
615
662
|
let trend = [];
|
|
616
663
|
try { const t = await io.readJson(files.trend); if (Array.isArray(t)) trend = t.slice(-20); } catch { trend = []; }
|
|
617
|
-
return { ok: true, audit: auditLines, trend };
|
|
664
|
+
return { ok: true, audit: auditLines, trend, trendVerdict: lastReport && lastReport.trendVerdict ? lastReport.trendVerdict : null };
|
|
618
665
|
}
|
|
619
666
|
return { ok: false, error: `unknown action: ${String(action)}` };
|
|
620
667
|
}
|
|
@@ -643,7 +690,7 @@ export function apply(ctx) {
|
|
|
643
690
|
// @changfenhuang/dsh-genui/lib/index.js:7772 ({name, order, text}, order via
|
|
644
691
|
// getSectionOrder). Guarded: if the service or getSectionOrder is missing we
|
|
645
692
|
// skip entirely (探针先行 red line: never guess, never endanger boot).
|
|
646
|
-
|
|
693
|
+
whenService(ctx, ['systemPrompt'], () => {
|
|
647
694
|
const sp = getService(ctx, 'systemPrompt');
|
|
648
695
|
if (sp && typeof sp.section === 'function' && typeof sp.getSectionOrder === 'function') {
|
|
649
696
|
ctx.effect(() => {
|
|
@@ -659,15 +706,17 @@ export function apply(ctx) {
|
|
|
659
706
|
}
|
|
660
707
|
}, 'dsh-steward: prompt section');
|
|
661
708
|
}
|
|
662
|
-
}
|
|
709
|
+
});
|
|
663
710
|
|
|
664
|
-
// 2) Host prefix route (panel + same-origin callers).
|
|
665
|
-
|
|
711
|
+
// 2) Host prefix route (panel + same-origin callers). Deferred until the
|
|
712
|
+
// service exists: on a cold boot webServer activates after this plugin, and a
|
|
713
|
+
// one-shot lookup silently registered nothing (the reported 404).
|
|
714
|
+
whenService(ctx, ['webServer'], () => {
|
|
666
715
|
const webServer = getService(ctx, 'webServer');
|
|
667
716
|
if (webServer && typeof webServer.register === 'function') {
|
|
668
717
|
ctx.effect(() => {
|
|
669
718
|
try {
|
|
670
|
-
|
|
719
|
+
const dispose = webServer.register({
|
|
671
720
|
kind: 'prefix',
|
|
672
721
|
path: ROUTE_PREFIX,
|
|
673
722
|
handler: async (req, res) => {
|
|
@@ -688,17 +737,20 @@ export function apply(ctx) {
|
|
|
688
737
|
}
|
|
689
738
|
},
|
|
690
739
|
});
|
|
740
|
+
wiring.route = 'registered';
|
|
741
|
+
return dispose;
|
|
691
742
|
} catch (e) {
|
|
743
|
+
wiring.route = 'failed: ' + String(e && e.message || e);
|
|
692
744
|
// Contained: a route registration failure must NOT fail the boot fiber.
|
|
693
745
|
try { if (typeof ctx.logger?.warn === 'function') ctx.logger.warn('[dsh-steward] route registration skipped: ' + String(e && e.message || e)); } catch { /* noop */ }
|
|
694
746
|
return undefined;
|
|
695
747
|
}
|
|
696
748
|
}, 'dsh-steward: routes');
|
|
697
749
|
}
|
|
698
|
-
}
|
|
750
|
+
});
|
|
699
751
|
|
|
700
|
-
// 3) Read-only health tool (kept from v0.1).
|
|
701
|
-
|
|
752
|
+
// 3) Read-only health tool (kept from v0.1). Same cold-boot race, same fix.
|
|
753
|
+
whenService(ctx, ['tools'], () => {
|
|
702
754
|
const tools = getService(ctx, 'tools');
|
|
703
755
|
if (tools && typeof tools.register === 'function') {
|
|
704
756
|
ctx.effect(() => {
|
|
@@ -774,7 +826,7 @@ export function apply(ctx) {
|
|
|
774
826
|
}
|
|
775
827
|
}, 'dsh-steward: act tool');
|
|
776
828
|
}
|
|
777
|
-
}
|
|
829
|
+
});
|
|
778
830
|
}
|
|
779
831
|
|
|
780
832
|
export default apply;
|
package/lib/report.js
CHANGED
|
@@ -575,7 +575,7 @@ function scoreFindings(findings, ctx) {
|
|
|
575
575
|
// A stale crash does NOT count: its disable action is blocked by the
|
|
576
576
|
// attribution gate, so promising a shortcut there would be dishonest.
|
|
577
577
|
const gateBlocked = f.id === 'boot-crash-latest' && f.data && f.data.stale === true;
|
|
578
|
-
if (ACTIONABLE_IDS.has(f.id) && !f.muted && !gateBlocked) {
|
|
578
|
+
if (ACTIONABLE_IDS.has(f.id) && !f.muted && !f.falsePositive && !gateBlocked) {
|
|
579
579
|
score += 15; reasons.push('有直达处置动作 +15');
|
|
580
580
|
} else if (gateBlocked) {
|
|
581
581
|
reasons.push('处置被归因门封锁,不计可直达');
|
|
@@ -606,6 +606,7 @@ function scoreFindings(findings, ctx) {
|
|
|
606
606
|
if (kinds > 0) mag += Math.min(10, kinds * 4);
|
|
607
607
|
}
|
|
608
608
|
if (mag) { score += mag; reasons.push('体量/条数 +' + mag); }
|
|
609
|
+
if (f.falsePositive) { score -= 40; reasons.push('用户标记判错 -40'); }
|
|
609
610
|
|
|
610
611
|
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
611
612
|
f.score = score;
|
|
@@ -857,6 +858,35 @@ function collectClientIdentity(paths, io, errors) {
|
|
|
857
858
|
return { entries, sameReal, drifted, collisions, unreadable, withoutId, scanned };
|
|
858
859
|
}
|
|
859
860
|
|
|
861
|
+
/**
|
|
862
|
+
* v0.9.15: turn 'better / worse / flat' into one Chinese sentence on the
|
|
863
|
+
* server. The client only renders it (USER.md: GUI is for humans, the host
|
|
864
|
+
* computes the words). previous is last run's persisted report; current is
|
|
865
|
+
* this run's { level, counts }. worseningStreak rides along in the report so
|
|
866
|
+
* consecutive declines can be counted without a second data store.
|
|
867
|
+
*/
|
|
868
|
+
export function summarizeTrend(previous, current) {
|
|
869
|
+
const cur = current || {};
|
|
870
|
+
const rank = (lv) => (lv === 'critical' ? 3 : lv === 'warning' ? 2 : 0);
|
|
871
|
+
const bad = (c) => (Number(c && c.critical) || 0) * 3 + (Number(c && c.warning) || 0) + rank(cur.level) * 2;
|
|
872
|
+
const curBad = bad(cur.counts);
|
|
873
|
+
const prev = previous && previous.summary ? previous.summary : null;
|
|
874
|
+
if (!prev) return { state: 'stable', worseningStreak: 0, text: '这是第一次体检,还没有可比的上一轮结论。' };
|
|
875
|
+
const prevBad = (Number(prev.counts && prev.counts.critical) || 0) * 3 + (Number(prev.counts && prev.counts.warning) || 0) + rank(prev.level) * 2;
|
|
876
|
+
const prevStreak = Number(previous.trendVerdict && previous.trendVerdict.worseningStreak) || 0;
|
|
877
|
+
if (curBad < prevBad) {
|
|
878
|
+
return { state: 'recovered', worseningStreak: 0, text: '比上一次体检好转了:风险项减少或严重项已消除。' };
|
|
879
|
+
}
|
|
880
|
+
if (curBad > prevBad) {
|
|
881
|
+
const streak = prevStreak + 1;
|
|
882
|
+
return { state: 'worsening', worseningStreak: streak,
|
|
883
|
+
text: streak >= 2
|
|
884
|
+
? '连续 ' + streak + ' 次体检都在变差,值得回头看审计趋势里这几次改了什么。'
|
|
885
|
+
: '比上一次体检多了风险项,先看看新出现的是哪一条。' };
|
|
886
|
+
}
|
|
887
|
+
return { state: 'stable', worseningStreak: 0, text: '与上一次体检相比没有明显变化。' };
|
|
888
|
+
}
|
|
889
|
+
|
|
860
890
|
export function collectReport(options = {}) {
|
|
861
891
|
const now = options.now || new Date();
|
|
862
892
|
const env = options.env || process.env;
|
|
@@ -1644,9 +1674,16 @@ export function collectReport(options = {}) {
|
|
|
1644
1674
|
.map((a) => (a && typeof a === 'object' ? a.id : a))
|
|
1645
1675
|
.filter((x) => typeof x === 'string'),
|
|
1646
1676
|
);
|
|
1677
|
+
const feedbackRaw = Array.isArray(options.feedback) ? options.feedback : [];
|
|
1678
|
+
const feedbackIds = new Set(
|
|
1679
|
+
feedbackRaw
|
|
1680
|
+
.filter((a) => a && typeof a === 'object' && typeof a.id === 'string')
|
|
1681
|
+
.map((a) => a.id),
|
|
1682
|
+
);
|
|
1647
1683
|
let mutedCount = 0;
|
|
1648
1684
|
for (const f of findings) {
|
|
1649
1685
|
f.muted = ackedIds.has(f.id);
|
|
1686
|
+
f.falsePositive = feedbackIds.has(f.id);
|
|
1650
1687
|
if (f.muted) mutedCount += 1;
|
|
1651
1688
|
}
|
|
1652
1689
|
|
|
@@ -1669,6 +1706,7 @@ export function collectReport(options = {}) {
|
|
|
1669
1706
|
: counts.warning > 0
|
|
1670
1707
|
? '有需要留意的风险项'
|
|
1671
1708
|
: '当前未发现异常,以下为常备建议';
|
|
1709
|
+
const trendVerdict = summarizeTrend(options.previous, { level, counts });
|
|
1672
1710
|
|
|
1673
1711
|
return {
|
|
1674
1712
|
generatedAt: now.toISOString ? now.toISOString() : String(now),
|
|
@@ -1680,8 +1718,9 @@ export function collectReport(options = {}) {
|
|
|
1680
1718
|
updates: updateInfo,
|
|
1681
1719
|
coverage,
|
|
1682
1720
|
status: errors.length > 0 ? 'degraded' : 'ok',
|
|
1683
|
-
summary: { level, headline, counts },
|
|
1684
1721
|
findings,
|
|
1722
|
+
summary: { level, headline, counts },
|
|
1723
|
+
trendVerdict,
|
|
1685
1724
|
limitations: [
|
|
1686
1725
|
'只读:不删文件、不代跑提权命令、不自动拉起/重启、不改启动流程。',
|
|
1687
1726
|
'不重复实现磁盘扫描 / 备份 / 归档 / 记忆,只指向现成能力。',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sidady/dsh-steward",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Guardian for DeepSeek Harness: a read-only health check (boot crashes, backup freshness, config drift, workspace growth, plugin updates, host-version compat, model availability) plus an explicitly authorized action layer (plan → single-use fingerprint → confirm → audit) covering cache cleaning, encrypted volatile-data backups, plugin enable/disable, knowledge capture, finding mute, and switching the default model.",
|
|
6
6
|
"keywords": [
|
package/smoke.mjs
CHANGED
|
@@ -1329,8 +1329,9 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
|
|
|
1329
1329
|
const clientLines = clientSrc.split("\n");
|
|
1330
1330
|
// 审计/趋势里存的是 UTC ISO;切片或把 T 换成空格都会原样把 UTC 贴给用户
|
|
1331
1331
|
// (东八区差 8 小时)。本地化必须走 localStamp,不能用字符串处理假装。
|
|
1332
|
-
const TQ = String.fromCharCode(39);
|
|
1333
|
-
const
|
|
1332
|
+
const TQ = String.fromCharCode(39), TDQ = String.fromCharCode(34);
|
|
1333
|
+
const swap = (q) => q + "T" + q + ", " + q + " " + q;
|
|
1334
|
+
const naive = clientLines.filter((l) => l.includes(".replace(") && (l.includes(swap(TQ)) || l.includes(swap(TDQ))));
|
|
1334
1335
|
check("no render path formats a timestamp by swapping the ISO T", naive.length === 0, naive.map((l) => l.trim().slice(0, 70)).join(" | "));
|
|
1335
1336
|
check("the local-time formatter exists", clientSrc.includes("function localStamp("));
|
|
1336
1337
|
const uses = (clientSrc.match(/localStamp\(/g) || []).length;
|
|
@@ -1376,6 +1377,103 @@ check('the cause survives into finding data for the panel', !!liveCrash && liveC
|
|
|
1376
1377
|
check("washes and rings derive from an accent", mixUses >= 6, String(mixUses));
|
|
1377
1378
|
check("the only saturated tokens are the six accents", /accentStability/.test(src) && /accentInfo/.test(src));
|
|
1378
1379
|
}
|
|
1380
|
+
|
|
1381
|
+
// — v0.9.13: 面板自己的路由没挂上时,不许只说「HTTP 404」 —
|
|
1382
|
+
{
|
|
1383
|
+
// 真机形态:改了插件源码触发宿主热重载,旧 fiber 被销毁、新 fiber 的路由
|
|
1384
|
+
// 没接上,面板只剩一行「读取失败: HTTP 404」。用户无法判断这是接口没挂、
|
|
1385
|
+
// 还是请求本身出错。这一组钉住三件事:认得出来、说得出怎么办、只重试一次。
|
|
1386
|
+
const clientSrc = fs.readFileSync(path.join(here, 'lib', 'client.js'), 'utf8');
|
|
1387
|
+
const callBody = clientSrc.slice(clientSrc.indexOf('async function call('), clientSrc.indexOf('function PanelIcon'));
|
|
1388
|
+
|
|
1389
|
+
check('a 404 on our own route is distinguished from a plain HTTP failure',
|
|
1390
|
+
/response\.status === 404/.test(callBody) && /接口没有挂上/.test(callBody));
|
|
1391
|
+
check('the 404 message names the hot-reload cause and the way out',
|
|
1392
|
+
/热重载/.test(callBody) && /停用再启用|重启 DSH Web/.test(callBody));
|
|
1393
|
+
check('the retry is bounded — one attempt, then it reports',
|
|
1394
|
+
/tries === 0/.test(callBody) && /call\(action, extra, tries \+ 1\)/.test(callBody));
|
|
1395
|
+
check('a generic failure still reports the server error verbatim',
|
|
1396
|
+
/\(data && data\.error\) \|\| 'HTTP ' \+ response\.status/.test(callBody));
|
|
1397
|
+
// 反向判据:不许把任何非 2xx 都说成「热重载」,那会把真正的服务端错误藏掉。
|
|
1398
|
+
check('the hot-reload wording is gated on 404, not on every non-2xx',
|
|
1399
|
+
!/接口没有挂上/.test(callBody.split('response.status === 404')[0]));
|
|
1400
|
+
}
|
|
1401
|
+
// — v0.9.14: 冷启动竞态 —— 服务没就绪时不许把注册整段跳过 —
|
|
1402
|
+
{
|
|
1403
|
+
// 真机形态:重启(不是热重载)后面板照样 404。原因不是路由写错了,而是
|
|
1404
|
+
// apply() 里用 ctx.get('webServer') 一次性探测:冷启动时 webServer 与 tools
|
|
1405
|
+
// 都比本插件晚 apply,那一次探测拿到 null,于是 if (webServer && ...) 整段
|
|
1406
|
+
// 跳过,路由与两个工具都没注册 —— 而每段外面都包了 fail-soft try/catch,
|
|
1407
|
+
// 所以只 warn 一行,面板与工具都静默消失。
|
|
1408
|
+
const idxSrc = fs.readFileSync(path.join(here, 'lib', 'index.js'), 'utf8');
|
|
1409
|
+
|
|
1410
|
+
check('the plugin defers to ctx.inject instead of a one-shot lookup',
|
|
1411
|
+
/ctx\.inject\(keys,/.test(idxSrc) && /function whenService\(/.test(idxSrc));
|
|
1412
|
+
check('every service-backed registration goes through whenService',
|
|
1413
|
+
(idxSrc.match(/whenService\(ctx, \['/g) || []).length >= 3);
|
|
1414
|
+
// 反向判据:旧的裸探测写法必须彻底消失,否则冷启动仍会静默跳过。
|
|
1415
|
+
check('no registration is left behind a bare try/getService probe',
|
|
1416
|
+
!/try \{\n\s*const webServer = getService\(/.test(idxSrc)
|
|
1417
|
+
&& !/try \{\n\s*const tools = getService\(/.test(idxSrc)
|
|
1418
|
+
&& !/try \{\n\s*const sp = getService\(/.test(idxSrc));
|
|
1419
|
+
// 静默失败是这次真正的病因:说得出「挂上了没有」比「应该挂上」有用。
|
|
1420
|
+
check('the wiring outcome is recorded, not just logged',
|
|
1421
|
+
/wiring\.route = 'registered'/.test(idxSrc) && /wiring\.route = 'failed: '/.test(idxSrc));
|
|
1422
|
+
check('ping reports the wiring outcome so a 404 is diagnosable',
|
|
1423
|
+
/wiring: \{ \.\.\.wiring \}/.test(idxSrc));
|
|
1424
|
+
// 走的是哪条路也要记:inject 与一次性探测在冷启动下行为完全不同,
|
|
1425
|
+
// 只看 route=registered 无法区分「真的等了」和「碰巧那次就绪了」。
|
|
1426
|
+
check('the wiring path records whether inject was used',
|
|
1427
|
+
/wiring\.via\[keys\.join\(','\)\] = 'inject'/.test(idxSrc)
|
|
1428
|
+
&& /wiring\.via\[keys\.join\(','\)\] = 'one-shot'/.test(idxSrc));
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
// — v0.9.15: 职能缺口补齐(P0 修真 bug / P1 误报反馈 / P2 趋势与直达)—
|
|
1433
|
+
{
|
|
1434
|
+
const clientSrc = fs.readFileSync(path.join(here, "lib", "client.js"), "utf8");
|
|
1435
|
+
const indexSrc = fs.readFileSync(path.join(here, "lib", "index.js"), "utf8");
|
|
1436
|
+
const actionsSrc = fs.readFileSync(path.join(here, "lib", "actions.js"), "utf8");
|
|
1437
|
+
const reportSrc = fs.readFileSync(path.join(here, "lib", "report.js"), "utf8");
|
|
1438
|
+
const clientLines = clientSrc.split(String.fromCharCode(10));
|
|
1439
|
+
|
|
1440
|
+
// P0-a: 保存时间的本地化不许再用「把 T 换成空格」的写法,两种引号都查。
|
|
1441
|
+
const SQ = String.fromCharCode(39), DQ = String.fromCharCode(34);
|
|
1442
|
+
const swaps = [SQ, DQ].map((q) => q + "T" + q + ", " + q + " " + q);
|
|
1443
|
+
const naive = clientLines.filter((l) => l.includes(".replace(") && swaps.some((s) => l.includes(s)));
|
|
1444
|
+
check("P0-a no ISO-T swap survives in either quote style", naive.length === 0, naive.map((l) => l.trim().slice(0, 60)).join(" | "));
|
|
1445
|
+
const savedLine = clientLines.find((l) => l.includes("setSaved(") && l.includes("res.savedAt"));
|
|
1446
|
+
check("P0-a the saved-at display calls localStamp", !!savedLine && savedLine.includes("localStamp("));
|
|
1447
|
+
|
|
1448
|
+
// P0-b: autoScanMinutes 必须是活设置,不是写死的 10 分钟。
|
|
1449
|
+
check("P0-b the client reads autoScanMinutes from the report payload", clientSrc.includes("cache.autoScanMinutes"));
|
|
1450
|
+
check("P0-b the hardcoded 10-minute threshold is gone", !clientSrc.includes("10 * 60 * 1000"));
|
|
1451
|
+
check("P0-b the host sends autoScanMinutes from settings", indexSrc.includes("autoScanMinutes: settingValue(currentSettings(), " + SQ + "ui.autoScanMinutes" + SQ + ")"));
|
|
1452
|
+
|
|
1453
|
+
// P1: 误报反馈是一个有数据文件、可 plan/execute、能影响评分、可撤销的真动作。
|
|
1454
|
+
check("P1 feedback has its own data file", indexSrc.includes("feedback: path.join(dataDir(), " + SQ + "feedback.json" + SQ + ")"));
|
|
1455
|
+
check("P1 plan handles feedback and unfeedback", actionsSrc.includes("kind === " + SQ + "feedback" + SQ + " || kind === " + SQ + "unfeedback" + SQ));
|
|
1456
|
+
check("P1 execute persists the feedback list", actionsSrc.includes("writeJson(deps.feedbackFile, list)"));
|
|
1457
|
+
check("P1 report marks falsePositive from options.feedback", reportSrc.includes("f.falsePositive = feedbackIds.has(f.id)"));
|
|
1458
|
+
check("P1 a false positive loses the actionable bonus", reportSrc.includes("&& !f.falsePositive && !gateBlocked"));
|
|
1459
|
+
check("P1 the score states the false-positive penalty", reportSrc.includes("用户标记判错 -40"));
|
|
1460
|
+
check("P1 the panel offers a 判错 button", clientSrc.includes("这条判错了"));
|
|
1461
|
+
|
|
1462
|
+
// P2-a: 趋势结论由服务端算好人话,恢复/连续恶化会主动说。
|
|
1463
|
+
check("P2-a summarizeTrend is exported from report.js", reportSrc.includes("export function summarizeTrend("));
|
|
1464
|
+
check("P2-a collectReport returns a trendVerdict", reportSrc.includes("trendVerdict,"));
|
|
1465
|
+
check("P2-a the text report states the trend", indexSrc.includes("趋势: ${report.trendVerdict.text}"));
|
|
1466
|
+
check("P2-a toasts fire on recovery",
|
|
1467
|
+
clientSrc.includes("tv.state === " + SQ + "recovered" + SQ + " ||") && clientSrc.includes("pushToast({ kind: " + SQ + "trend" + SQ));
|
|
1468
|
+
check("P2-a ToastCard can paint an ok tone", clientSrc.includes("t.severity === " + SQ + "ok" + SQ + " ? C.ok"));
|
|
1469
|
+
check("P2-a AuditTab prints the trend verdict", clientSrc.includes("趋势结论 · "));
|
|
1470
|
+
|
|
1471
|
+
// P2-b: 不假造直达动作;运行期自检结果要给用户看见。
|
|
1472
|
+
check("P2-b the phantom model-default shortcut is gone", !clientSrc.includes("" + SQ + "model-health" + SQ + ": " + SQ + "model-default" + SQ) && clientSrc.includes("" + SQ + "backup-freshness" + SQ + ": " + SQ + "backup" + SQ));
|
|
1473
|
+
check("P2-b the host sends wiring with the report", indexSrc.includes("wiring: { ...wiring }"));
|
|
1474
|
+
check("P2-b the panel shows the wiring outcome", clientSrc.includes("接口已挂上"));
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1379
1477
|
console.log('');
|
|
1380
1478
|
console.log(`RESULT: ${pass} passed, ${fail} failed`);
|
|
1381
1479
|
process.exit(fail === 0 ? 0 : 1);
|