dsh-my-guardian 0.4.0 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -5,94 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.0] - 2026-09-07
8
+ ## [0.4.2] - 2026-09-17
9
9
 
10
10
  ### 变更
11
11
 
12
- - feat(observability): #155 插件状态查询聚合——统一 status-query 事件 + /plugin-status API (#170)
13
- - chore(plugins): #165 清理失效的 dsh.client.inject 声明(13 插件) (#167)
14
- - feat(guard): #144 启动名册静态预检——startup-issues.json + 面板置顶 + 不阻断启动 (#151)
12
+ - docs: skill 合并 12→10 并拆分超限文件,修 observability 聚合端点缺陷
13
+ - docs(清理): #341 文档瘦身 23765 → 8309 行并固化精简规范 (#348)
14
+ - feat(gates): #323 新增包发布卫生门禁(pack 内容 + 字段断言 + README 引用面) (#333)
15
+ - chore(cleanup): #315 清理 unused-local-variable/useless-expression(含共享件假阳性判定) (#319)
16
+ - fix(test): cucumber-js --import glob 改用双引号,兼容 Windows
17
+ - fix(security): 批量修复 'Insecure temporary file' 安全漏洞
18
+ - fix: 移除未使用的stat导入,修复文件系统竞争条件
19
+ - fix: update tests and rebuild client.js for new icons
20
+ - chore: 项目全面优化和完善
15
21
 
16
- ## [0.3.6] - 2026-09-04
22
+ ## [0.4.1] - 2026-09-10
17
23
 
18
24
  ### 变更
19
25
 
20
- - fix(guardian): 事件日志不再访问 entry.id getter——构造期 parent.tree 未就绪会抛错炸掉 DSH 启动(隔离实例复现)
21
- - fix(guardian): 诊断面板可读性 + entry 标识修复——插件失败时不再"看不懂/兜不住"
22
- - fix(guardian): #86 发版一致性修复——package.json 0.3.5 + CHANGELOG 0.3.5 段(rebase 冲突还原恢复)
23
- - chore(release): dsh-my-guardian v0.3.5(#86 依赖预检 + 验证清单)
24
- - feat(guardian): #86 候选区插件依赖预检 + 失败分类 (#124)
25
- - docs: #106 安装命令统一加 --trust-lockfile (#113)
26
-
27
- ## [0.3.5] - 2026-09-04
28
-
29
- ### 新增
30
-
31
- - [#86](https://github.com/baosfeng/my-dsh-plugins/issues/86) 候选区插件依赖预检 + 失败分类:热挂载前校验依赖就绪,失败按类别诊断(缺失依赖/锁定冲突/加载错误),诊断面板展示分类详情
32
-
33
- ### 变更
34
-
35
- - 验证清单与效果图同步(发版前功能级验证归档)
36
-
37
- ## [0.3.4] - 2026-09-02
38
-
39
- ### 变更
40
-
41
- - fix(scripts): #72 插件依赖未随安装自动安装(dsh-shared 未发布 npm) (#96)
42
-
43
- ## [0.3.3] - 2026-09-01
44
-
45
- ### 变更
46
-
47
- - fix(ui): 9 个插件未定义 token danger-primary 改用 error-primary(DSH 主题仅定义 business/error/success/warn)
48
-
49
- ## [0.3.2] - 2026-08-28
50
-
51
- ### 变更
52
-
53
- - feat(ui): dsh-my-guardian 插件治理面板翻新——开关/图标/日志层级(issue #54)
54
- - refactor(shared): 抽取 dsh-shared 共享工具包,10 个插件迁移消除重复实现(issue #45)
55
- - chore(deps): 升级 react 19 兼容性——13 个插件 peer 声明 ^18.2.0 || ^19.2.0(issue #49)
56
- - style(format): 全仓 prettier 格式化(issue #44)
57
- - fix(ci): 并行化后的两个失败——Syntax check 对无 lib 的插件(dsh-plugin-dev-mode)用 if 结构;guardian waitFor 超时 3s→10s(并行环境更稳)
58
-
59
- ## [0.3.1] - 2026-08-27
60
-
61
- ### 变更
62
-
63
- - **npm 包名改为 `dsh-my-guardian`**:`bsfeng-dsh-guardian` → `dsh-my-guardian`,与 `dsh-my-*` 系列(dsh-my-skill-manager / dsh-my-plugin-manager / dsh-my-memory)统一,目录名 = 包名 = tag 名,避免与 npm 上他人同名包(`dsh-guardian`,lss1213)混淆。安装命令变为 `dsh plugin --profile web add dsh-my-guardian`。API 路径(`/guardian/api/*`)、状态文件路径(`$DSH_HOME/guardian/state.json`)、插件行 id(`guardian`)保持兼容。
64
-
65
- ## [0.3.0] - 2026-08-26
66
-
67
- ### 变更
68
-
69
- - refactor(guardian): 移除 dsh-better-sidebar 第三方依赖(#22)
70
- - docs+test: 全面审查修复——文档同步补全 + mermaid 测试增强
71
-
72
- ## [0.2.1] - 2026-08-25
73
-
74
- ### 变更
75
-
76
- - **npm 页面元数据优化**:description 改为中英双语(中文在前);README 效果截图引用改为绝对 URL(unpkg),npm 包页面可直接显示图片。
77
-
78
- ## [0.2.0] - 2026-08-25
79
-
80
- ### 变更
81
-
82
- - **npm 包名改为 `bsfeng-dsh-guardian`**:npm 上 `dsh-guardian` 已被他人占用(lss1213 的插件),按用户确认改为 bsfeng 前缀。安装命令变为 `dsh plugin --profile web add link:<仓库路径>/plugins/dsh-guardian`(link 安装 key 同步)。API 路径(`/guardian/api/*`)与状态文件路径(`$DSH_HOME/guardian/state.json`)保持兼容。
83
- - **Server 端按 P2 模块拆分**:`lib/index.js`(636 行)拆分为 state/fence/events/mount/api 子模块;**Client 端方案 B 拆分**(src 模板 + 5 片段 + build 拼接)。
84
- - **README 补充真实 DSH 实例效果截图**(assets/panel-main.png + panel-error-detail.png,隔离实例实测)。
85
- - 行为不变(重构 + 改名)。
86
-
87
- ## [0.1.0] - 2026-08-23
88
-
89
- ### Added
90
-
91
- - 插件治理(dsh-guardian)首个版本:
92
- - **两段式加载**:新插件写入候选区 `cordis.staged.json`(与 `cordis.patch.yml` 同目录),DSH 启动完成后由守护插件逐个热挂载,不阻塞启动。
93
- - **失败隔离**:候选插件挂载失败自动记录(尝试次数 + 错误),连续失败 3 次冻结,不再自动重试。
94
- - **成功转正**:挂载成功的插件自动进入守护插件的持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复。
95
- - **安全模式**:一键跳过所有候选/已转正插件的加载,快速恢复被插件搞坏的环境。
96
- - **诊断面板**:dsh-better-sidebar 侧边栏页签(状态列表 / 重试 / 移除 / 错误详情 / 安全模式开关)。
97
- - **事件监控**:`hmr/config-update-failed`、`loader/entry-init`、`loader/partial-dispose` 诊断事件记录。
98
- - HTTP API `/guardian/api/*`(loopback 信任围栏)。
26
+ - chore(quality): 补 TS 源码尺寸门禁 + dsh-my-guardian 契约测试
27
+ - feat(ts): 补齐 dsh-my-context / dsh-my-guardian 的 client 端迁移
28
+ - feat(dsh-my-guard): migrate to TypeScript
29
+ - fix(dsh-my-guardian): 设置页 slots 首屏时序修复(ctx.get strict=false + 防回归测试)
package/README.md CHANGED
@@ -1,132 +1,55 @@
1
1
  # dsh-my-guardian — 插件守护
2
2
 
3
- > DSH(DeepSeek Harness)插件隔离与失败兜底:**新装/刚更新的插件先进候选区,由守护插件在启动完成后逐个热挂载——成功自动转正,失败自动隔离记录,连续失败冻结,一键安全模式**,配套侧边栏诊断面板。
3
+ [![插件生态](https://img.shields.io/badge/插件生态-topic%20dsh-4d6bfe)](https://github.com/topics/dsh)
4
4
 
5
- ## 为什么需要
5
+ **dsh-my-guardian**:DSH 插件隔离与失败兜底——新装/刚更新的插件先进候选区,启动完成后由守护插件逐个热挂载,成功自动转正、失败自动隔离、连续失败冻结,附一键安全模式与侧边栏诊断面板。
6
6
 
7
- DSH 的 Cordis 插件加载是 **all-or-nothing**:启动时任何插件 `import` 失败 / `apply` 抛错 / 依赖缺失,整个 `dsh web` 都会起不来(fail-loud 退出)。装一个新插件把环境搞坏,是插件用户最常踩的坑。
8
-
9
- 本插件借鉴 VS Code(Extension Host 崩溃自动重启 + 禁用问题扩展)、IntelliJ(Dynamic Plugins 运行时装卸)、systemd(失败计数超限即停)的思路,利用 DSH loader 的**运行时动态挂载 API**(失败可捕获、可回滚),把"新插件"从启动路径挪到启动之后:
7
+ ![插件守护面板:候选失败隔离 + 转正运行中 + 安全模式开关](https://unpkg.com/dsh-my-guardian/assets/panel-main.png)
10
8
 
11
- ```
12
- cordis.patch.yml(核心区:只放稳定插件,守护插件自身)
13
- ↓ 新插件写进
14
- cordis.staged.json(候选区)
15
- ↓ DSH 启动完成后
16
- 守护插件逐个依赖预检 + 热挂载
17
- ├─ 成功 → 自动转正(进入持久化清单,每次启动自动恢复)
18
- └─ 失败 → 自动隔离(记录次数 + 失败类型/错误,连续 3 次冻结)
19
- ```
9
+ ![失败自动隔离:错误详情可查](https://unpkg.com/dsh-my-guardian/assets/panel-error-detail.png)
20
10
 
21
11
  ## 功能
22
12
 
23
- - **两段式加载**:新装/更新插件先进候选区,启动不阻塞、坏插件不拖垮进程。
24
- - **失败自动隔离**:挂载失败自动记录(尝试次数 + 失败类型 + 错误摘要),连续失败 **3 次冻结**,需手动重试。
25
- - **挂载前依赖预检**:候选插件挂载前检查 `peerDependencies`——仓库内 `dsh-*` 依赖是否安装、官方依赖版本是否满足;缺失/不满足即标记「依赖缺失」并给出安装建议(如 `dsh plugin add <依赖>`),不进入挂载。
26
- - **成功自动转正**:挂载成功的插件进入持久化清单(`$DSH_HOME/guardian/state.json`),后续每次启动自动恢复挂载。
27
- - **安全模式**:一键跳过所有候选/已转正插件,快速恢复被插件搞坏的环境。
28
- - **诊断面板**:dsh-better-sidebar 侧边栏"插件守护"页签——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
29
- - **运行中热挂载**:DSH 运行期间往候选区加条目,自动挂载,无需重启。
13
+ - **两段式加载**:新插件先进候选区,坏插件不再拖垮 `dsh web` 启动(DSH 启动名册是 all-or-nothing,任一插件失败即整个进程起不来)。
14
+ - **失败自动隔离**:挂载失败记录尝试次数 + 失败类型 + 错误摘要,连续失败 **3 次冻结**,需手动重试。
15
+ - **挂载前依赖预检**:检查候选插件 `peerDependencies` 是否安装、版本是否满足,不满足标记「依赖缺失」并给安装建议,不进入挂载。
16
+ - **成功自动转正**:挂载成功的插件进入持久化清单,后续启动自动恢复。
17
+ - **运行中热挂载**:运行期间往候选区加条目即自动挂载,无需重启。
18
+ - **安全模式**:一键跳过全部候选/已转正插件,快速救回被插件搞坏的环境。
19
+ - **诊断面板**:侧边栏「插件守护」页签(宿主原生扩展点)——状态列表 / 重试 / 移除 / 错误详情 / 失败分类徽标 / 安全模式开关 / 最近事件。
30
20
 
31
21
  ## 安装
32
22
 
33
- > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。依赖 `dsh-shared`(server 端共享工具包)随 npm 自动安装,无需手动处理。
34
-
35
- ### 方式一:dsh plugin(推荐)
23
+ > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-my-guardian --trust-lockfile`——无需克隆本仓库;依赖 `dsh-shared` 随 npm 自动安装。link 方式供本仓库开发者使用。
36
24
 
37
25
  ```sh
38
- # 1) 克隆本仓库(任意目录)
39
26
  git clone https://github.com/baosfeng/my-dsh-plugins.git
40
- # 2) 以本地 link 方式安装(将 <仓库路径> 替换为上面的克隆目录)
41
27
  dsh plugin --profile web add link:<仓库路径>/plugins/dsh-my-guardian
42
28
  ```
43
29
 
44
- 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)。
45
-
46
- ### 方式二:手动
47
-
48
- 1. 克隆本仓库后,在 `~/.dsh/profiles/web/package.json` 的 dependencies 增加 `"dsh-my-guardian": "link:<仓库路径>/plugins/dsh-my-guardian"`
49
- 2. `cd ~/.dsh/profiles/web && pnpm install`
50
- 3. 在 `~/.dsh/profiles/web/cordis.patch.yml` 的 insert 列表**第一行**加:
51
-
52
- ```yaml
53
- - insert:
54
- - id: guardian
55
- name: 'dsh-my-guardian'
56
- ```
57
-
58
- > ⚠️ **守护插件自身必须放在正式核心区第一行**(它自己是"看门狗":启动时加载,随后才管理候选插件)。
30
+ 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)。手动安装:在 profile 的 `cordis.patch.yml` insert 列表**第一行**加 `id: guardian` / `name: 'dsh-my-guardian'`——守护插件自己是看门狗,必须最先加载。
59
31
 
60
32
  ## 使用
61
33
 
62
- ### 候选区文件
63
-
64
- 新建 `~/.dsh/profiles/web/cordis.staged.json`(与 `cordis.patch.yml` 同目录):
34
+ 候选区文件 `~/.dsh/profiles/web/cordis.staged.json`(与 `cordis.patch.yml` 同目录),每项为 `{ id, name, config? }`:`id` 唯一标识、`name` 插件包名(profile 内可解析)、`config` 可选配置。
65
35
 
66
36
  ```json
67
37
  [{ "id": "my-plugin", "name": "dsh-my-plugin", "config": { "option": 1 } }]
68
38
  ```
69
39
 
70
- - `id`:唯一标识(不能与现有插件行 id 冲突)
71
- - `name`:插件包名(profile node_modules 中可解析)
72
- - `config`:可选,插件的配置
73
-
74
- 写入文件后守护插件会自动挂载;挂载成功该条自动从候选文件移除(转正),失败则保留(状态记录在面板中可见)。
75
-
76
- ### 面板
77
-
78
- 侧边栏 → "插件守护"页签:
40
+ 写入后自动挂载:成功则该条从候选文件移除(转正),失败则保留。面板状态:运行中(可移除)/ 待加载(安全模式等)/ 失败 ×N(重试或移除)/ 冻结(连续失败 3 次,重试解除冻结)。失败条目带失败类型徽标(依赖缺失 / 代码错误 / 其他),依赖缺失时附安装建议命令。
79
41
 
80
- | 状态 | 含义 | 操作 |
81
- | ------- | ---------------------- | ---------------------- |
82
- | 运行中 | 已挂载 | 移除 |
83
- | 待加载 | 尚未处理(安全模式等) | — |
84
- | 失败 ×N | 挂载失败 N 次 | 重试 / 移除 |
85
- | 冻结 | 连续失败 3 次 | 重试(解除冻结)/ 移除 |
86
-
87
- 失败条目额外带**失败类型徽标**(依赖缺失 / 代码错误 / 其他),依赖缺失时并展示安装建议命令(如 `dsh plugin add dsh-shared`)。
88
-
89
- ### 启动名册静态预检(issue #144)
90
-
91
- 候选区只兜住"走候选区"的插件;**直接写进启动名册(`cordis.patch.yml` / profile / bundles)的插件仍由 DSH 启动时 all-or-nothing 加载**。守护在每次启动时对名册做静态预检(不实际加载插件):
92
-
93
- - **包可解析**:名册中 `dsh-*` 插件的包必须在 profile node_modules 中存在
94
- - **peerDependencies 满足**:复用候选挂载的依赖预检
95
- - **无重复 entry id**:名册中同一 id 出现多次(加载时 `EntryGroup.update` 会抛 `duplicate loader entry id`)
96
-
97
- 发现的问题写入 `$DSH_HOME/guardian/startup-issues.json`(原子写,名册健康也写空报告 + 检查时间),面板「最近事件」顶部**置顶**展示启动区问题,每条给出修复命令(`dsh plugin add ...`)与移除提示。**预检失败不阻断启动**——只记录、只告警,最坏情况是为启动失败留下排查记录。
98
-
99
- ### 效果截图(真实 DSH 实例验证)
100
-
101
- 侧边栏"插件守护"诊断面板(独立 3081 端口隔离 DSH 实例实测):
102
-
103
- ![插件守护面板:候选失败隔离 + 转正运行中 + 安全模式开关](https://unpkg.com/dsh-my-guardian/assets/panel-main.png)
104
-
105
- ![失败自动隔离:错误详情可查](https://unpkg.com/dsh-my-guardian/assets/panel-error-detail.png)
106
-
107
- > 截图环境:隔离 DSH 验证实例(`/tmp/dsh-3085`,端口 3085)。候选区写入 `fake-needy`(peer 依赖 `fake-never-installed-dep` 缺失 → 依赖预检拦截,分类徽标「依赖缺失」+ 安装建议 `dsh plugin add fake-never-installed-dep` + 自动隔离冻结)与 `fake-simple`(无依赖 → 热挂载成功自动转正「运行中」)。
42
+ **启动名册静态预检**:直接写进 `cordis.patch.yml` / profile / bundles 的插件仍由 DSH 启动时 all-or-nothing 加载,守护每次启动对名册做静态预检(包可解析、`peerDependencies` 满足、无重复 entry id),问题写入 `$DSH_HOME/guardian/startup-issues.json` 并在面板「最近事件」置顶展示;**预检只记录告警,不阻断启动**。
108
43
 
109
44
  ## 配置
110
45
 
111
- **无应用层配置项**(`apply(ctx)` 不接收 config 参数,设置页无可视化配置入口;插件激活即生效)。运行时状态与开关:
112
-
113
- - **状态文件**:`$DSH_HOME/guardian/state.json`(`~/.dsh/guardian/state.json`)——持久化候选/转正清单、失败次数、安全模式、事件日志。损坏自动降级为空状态,不影响启动。
114
- - **安全模式**:面板开关(或直接编辑 `state.json` 的 `safeMode: true`)。开启后所有候选/已转正插件不再加载;恢复环境后关闭开关即重新加载。
46
+ **无应用层配置项**(插件激活即生效)。运行时状态:`$DSH_HOME/guardian/state.json` 持久化候选/转正清单、失败次数、安全模式与事件日志(损坏自动降级为空状态);**安全模式**为面板开关,也可直接编辑 `state.json` 的 `safeMode: true`,开启后候选/已转正插件都不加载。
115
47
 
116
48
  ## 诚实的边界
117
49
 
118
- - 本插件提供的是**加载时序与失败处置的兜底隔离**,不是进程级资源隔离(server 端插件仍在同一 Node 进程,client 端仍在同一浏览器页面)。进程级隔离需要 DSH 框架支持 worker/子进程 + RPC(演进建议见 [docs/插件治理/概述.md](../../docs/插件治理/概述.md))。
119
- - **启动阶段的正式核心区(`cordis.patch.yml`)仍遵循 all-or-nothing**。请把一切新插件先放入候选区验证,稳定后再考虑放核心区。
120
-
121
- ## 依赖
122
-
123
- | 依赖 | 用途 | 可选 |
124
- | -------------------- | -------------------------------------------------------------------------------------------------------------- | -------------- |
125
- | `cordis` | 插件运行时 | 是(宿主提供) |
126
- | `dsh-better-sidebar` | 侧边栏「插件守护」诊断面板(**可选增强,不参与依赖声明**:未安装时自动跳过面板注册,API / 候选区治理不受影响) | 否(可选增强) |
127
- | `react` | client 端组件 | 是(宿主提供) |
128
- | `dsh-my-notify` | 失败 / 冻结事件浏览器通知 | 是 |
50
+ - 提供的是**加载时序与失败处置的兜底隔离**,不是进程级资源隔离(server 端插件仍在同一 Node 进程,client 端仍在同一浏览器页面)。
51
+ - **启动阶段的正式核心区(`cordis.patch.yml`)仍遵循 all-or-nothing**:新插件请先进候选区验证,稳定后再考虑放核心区。
129
52
 
130
53
  ## 相关文档
131
54
 
132
- → [插件治理概述](../../docs/插件治理/概述.md) · [需求清单](../../docs/插件治理/需求清单.md)
55
+ → [插件治理概述](../../docs/插件治理/概述.md)
Binary file
Binary file
package/lib/api.js CHANGED
@@ -3,196 +3,191 @@
3
3
  * webServer service may mount after the guardian; poll ticks retry until it
4
4
  * appears), trust fence, method dispatch and the panel snapshot.
5
5
  */
6
- import { readStagedFile, writeStagedFile } from './state.js'
7
- import { isTrustedApiRequest, readJsonBody, writeJson } from 'dsh-shared'
8
-
6
+ import { readStagedFile, writeStagedFile } from './state.js';
7
+ import { isTrustedApiRequest, readJsonBody, writeJson } from 'dsh-shared';
9
8
  /** Bind the API entry points to one guardian instance's shared state. */
10
9
  export function createApi(ctx, shared) {
11
- return {
12
- ensureApi: () => ensureApi(ctx, shared),
13
- snapshot: () => snapshot(shared),
14
- }
10
+ return {
11
+ ensureApi: () => ensureApi(ctx, shared),
12
+ snapshot: () => snapshot(shared),
13
+ };
15
14
  }
16
-
17
15
  /**
18
16
  * Register the /guardian/api routes once the webServer service appears
19
17
  * (optional surface; CLI profiles skip it). Retried on every poll tick.
20
18
  */
21
19
  function ensureApi(ctx, shared) {
22
- if (shared.apiRegistered) return
23
- const webServer = ctx.get('webServer')
24
- if (webServer === undefined) return
25
- const webRuntime = ctx.get('webRuntime')
26
- shared.apiRegistered = true
27
- try {
28
- ctx.effect(
29
- () =>
30
- webServer.register({
31
- kind: 'prefix',
32
- path: '/guardian/api',
33
- handler: (request, response) => handleApiRequest(ctx, shared, webRuntime, request, response),
34
- }),
35
- 'dsh-my-guardian: /guardian/api routes',
36
- )
37
- } catch (error) {
38
- // registration failed: allow a later poll tick to retry
39
- shared.apiRegistered = false
40
- ctx.logger?.warn(
41
- `[dsh-my-guardian] api registration failed: ${error instanceof Error ? error.message : String(error)}`,
42
- )
43
- }
20
+ if (shared.apiRegistered)
21
+ return;
22
+ const webServer = ctx.get('webServer');
23
+ if (webServer === undefined)
24
+ return;
25
+ const webRuntime = ctx.get('webRuntime');
26
+ shared.apiRegistered = true;
27
+ try {
28
+ ctx.effect(() => webServer.register({
29
+ kind: 'prefix',
30
+ path: '/guardian/api',
31
+ handler: (request, response) => handleApiRequest(ctx, shared, webRuntime, request, response),
32
+ }), 'dsh-my-guardian: /guardian/api routes');
33
+ }
34
+ catch (error) {
35
+ // registration failed: allow a later poll tick to retry
36
+ shared.apiRegistered = false;
37
+ ctx.logger?.warn(`[dsh-my-guardian] api registration failed: ${error instanceof Error ? error.message : String(error)}`);
38
+ }
44
39
  }
45
-
46
40
  /** Unified route handler: fence → method dispatch → 404/error fallback. */
47
41
  async function handleApiRequest(ctx, shared, webRuntime, request, response) {
48
- if (!isTrustedApiRequest(request, webRuntime?.trustedHosts ?? [])) {
49
- writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
50
- return
51
- }
52
- const url = new URL(request.url ?? '/', 'http://dsh.internal')
53
- const method = url.pathname.startsWith('/guardian/api/') ? url.pathname.slice('/guardian/api/'.length) : ''
54
- try {
55
- await dispatchApiMethod(ctx, shared, method, request, response)
56
- } catch (error) {
57
- writeJson(response, 400, {
58
- ok: false,
59
- error: { message: error instanceof Error ? error.message : String(error) },
60
- })
61
- }
42
+ if (!isTrustedApiRequest(request, webRuntime?.trustedHosts ?? [])) {
43
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
44
+ return;
45
+ }
46
+ const url = new URL(request.url ?? '/', 'http://dsh.internal');
47
+ const method = url.pathname.startsWith('/guardian/api/') ? url.pathname.slice('/guardian/api/'.length) : '';
48
+ try {
49
+ // 启动扫描(loadState + promoted 挂载 + API 注册)完成前不派发:否则
50
+ // retry/state 会读到"半加载"的空 state——CI 上实测变成 404 not found
51
+ // (固定 sleep 赌 loadState 跑完,慢机器上必然偶发赌输)。
52
+ await shared.bootPromise;
53
+ await dispatchApiMethod(ctx, shared, method, request, response);
54
+ }
55
+ catch (error) {
56
+ writeJson(response, 400, {
57
+ ok: false,
58
+ error: { message: error instanceof Error ? error.message : String(error) },
59
+ });
60
+ }
62
61
  }
63
-
64
62
  /** Dispatch one API method to its handler; unknown methods get 404. */
65
63
  async function dispatchApiMethod(ctx, shared, method, request, response) {
66
- if (method === 'state' && request.method === 'GET') {
67
- writeJson(response, 200, { ok: true, value: shared.snapshot() })
68
- return
69
- }
70
- if (request.method !== 'POST') {
71
- writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } })
72
- return
73
- }
74
- if (method === 'staged') {
75
- await handleStagedPost(ctx, shared, request, response)
76
- return
77
- }
78
- if (method === 'retry') {
79
- await handleRetryPost(ctx, shared, request, response)
80
- return
81
- }
82
- if (method === 'remove') {
83
- await handleRemovePost(ctx, shared, request, response)
84
- return
85
- }
86
- if (method === 'safemode') {
87
- await handleSafemodePost(ctx, shared, request, response)
88
- return
89
- }
90
- writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } })
64
+ if (method === 'state' && request.method === 'GET') {
65
+ writeJson(response, 200, { ok: true, value: shared.snapshot() });
66
+ return;
67
+ }
68
+ if (request.method !== 'POST') {
69
+ writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } });
70
+ return;
71
+ }
72
+ if (method === 'staged') {
73
+ await handleStagedPost(ctx, shared, request, response);
74
+ return;
75
+ }
76
+ if (method === 'retry') {
77
+ await handleRetryPost(ctx, shared, request, response);
78
+ return;
79
+ }
80
+ if (method === 'remove') {
81
+ await handleRemovePost(ctx, shared, request, response);
82
+ return;
83
+ }
84
+ if (method === 'safemode') {
85
+ await handleSafemodePost(ctx, shared, request, response);
86
+ return;
87
+ }
88
+ writeJson(response, 404, { ok: false, error: { message: 'unknown guardian API method' } });
91
89
  }
92
-
93
90
  /** POST /guardian/api/staged — add a candidate entry and mount it. */
94
- async function handleStagedPost(ctx, shared, request, response) {
95
- const payload = await readJsonBody(request)
96
- const id = typeof payload.id === 'string' ? payload.id : ''
97
- const name = typeof payload.name === 'string' ? payload.name : ''
98
- if (id === '' || name === '') {
99
- writeJson(response, 400, { ok: false, error: { message: 'id and name are required' } })
100
- return
101
- }
102
- if (shared.conflictOf(id) !== null) {
103
- writeJson(response, 409, { ok: false, error: { message: `id "${id}" already in use` } })
104
- return
105
- }
106
- const entries = await readStagedFile(shared.stagedFile)
107
- if (entries.some((item) => item?.id === id)) {
108
- writeJson(response, 409, {
109
- ok: false,
110
- error: { message: `"${id}" already in the staged file` },
111
- })
112
- return
113
- }
114
- entries.push({ id, name, ...(payload.config !== undefined ? { config: payload.config } : {}) })
115
- const writeError = await writeStagedFile(shared.stagedFile, entries)
116
- if (writeError !== null) throw writeError
117
- await shared.scanStaged()
118
- writeJson(response, 200, { ok: true, value: shared.snapshot() })
91
+ async function handleStagedPost(_ctx, shared, request, response) {
92
+ const payload = await readJsonBody(request);
93
+ const id = typeof payload.id === 'string' ? payload.id : '';
94
+ const name = typeof payload.name === 'string' ? payload.name : '';
95
+ if (id === '' || name === '') {
96
+ writeJson(response, 400, { ok: false, error: { message: 'id and name are required' } });
97
+ return;
98
+ }
99
+ if (shared.conflictOf(id) !== null) {
100
+ writeJson(response, 409, { ok: false, error: { message: `id "${id}" already in use` } });
101
+ return;
102
+ }
103
+ const entries = await readStagedFile(shared.stagedFile);
104
+ if (entries.some((item) => item?.id === id)) {
105
+ writeJson(response, 409, {
106
+ ok: false,
107
+ error: { message: `"${id}" already in the staged file` },
108
+ });
109
+ return;
110
+ }
111
+ entries.push({ id, name, ...(payload.config !== undefined ? { config: payload.config } : {}) });
112
+ const writeError = await writeStagedFile(shared.stagedFile, entries);
113
+ if (writeError !== null)
114
+ throw writeError;
115
+ await shared.scanStaged();
116
+ writeJson(response, 200, { ok: true, value: shared.snapshot() });
119
117
  }
120
-
121
118
  /** POST /guardian/api/retry — manual unfreeze of a staged/promoted entry. */
122
- async function handleRetryPost(ctx, shared, request, response) {
123
- const payload = await readJsonBody(request)
124
- const id = typeof payload.id === 'string' ? payload.id : ''
125
- const outcome = await shared.retryEntry(id)
126
- if (outcome === null) {
127
- writeJson(response, 404, { ok: false, error: { message: `no such entry "${id}"` } })
128
- } else {
129
- writeJson(response, 200, { ok: true, value: { outcome } })
130
- }
119
+ async function handleRetryPost(_ctx, shared, request, response) {
120
+ const payload = await readJsonBody(request);
121
+ const id = typeof payload.id === 'string' ? payload.id : '';
122
+ const outcome = await shared.retryEntry(id);
123
+ if (outcome === null) {
124
+ writeJson(response, 404, { ok: false, error: { message: `no such entry "${id}"` } });
125
+ }
126
+ else {
127
+ writeJson(response, 200, { ok: true, value: { outcome } });
128
+ }
131
129
  }
132
-
133
130
  /** POST /guardian/api/remove — drop an entry everywhere. */
134
- async function handleRemovePost(ctx, shared, request, response) {
135
- const payload = await readJsonBody(request)
136
- const id = typeof payload.id === 'string' ? payload.id : ''
137
- await shared.removeEntry(id)
138
- writeJson(response, 200, { ok: true, value: shared.snapshot() })
131
+ async function handleRemovePost(_ctx, shared, request, response) {
132
+ const payload = await readJsonBody(request);
133
+ const id = typeof payload.id === 'string' ? payload.id : '';
134
+ await shared.removeEntry(id);
135
+ writeJson(response, 200, { ok: true, value: shared.snapshot() });
139
136
  }
140
-
141
137
  /** POST /guardian/api/safemode — toggle safe mode (R5). */
142
- async function handleSafemodePost(ctx, shared, request, response) {
143
- const payload = await readJsonBody(request)
144
- const enabled = payload.enabled === true
145
- shared.state.safeMode = enabled
146
- shared.logEvent('safe-mode', enabled ? 'safe mode enabled' : 'safe mode disabled')
147
- if (enabled) {
148
- for (const id of [...shared.mounted]) await shared.unmount(id)
149
- } else {
150
- shared.attempted.clear()
151
- await shared.scanStaged()
152
- await shared.mountPromoted()
153
- }
154
- shared.persistSoon()
155
- writeJson(response, 200, { ok: true, value: shared.snapshot() })
138
+ async function handleSafemodePost(_ctx, shared, request, response) {
139
+ const payload = await readJsonBody(request);
140
+ const enabled = payload.enabled === true;
141
+ shared.state.safeMode = enabled;
142
+ shared.logEvent('safe-mode', enabled ? 'safe mode enabled' : 'safe mode disabled');
143
+ if (enabled) {
144
+ for (const id of [...shared.mounted])
145
+ await shared.unmount(id);
146
+ }
147
+ else {
148
+ shared.attempted.clear();
149
+ await shared.scanStaged();
150
+ await shared.mountPromoted();
151
+ }
152
+ shared.persistSoon();
153
+ writeJson(response, 200, { ok: true, value: shared.snapshot() });
156
154
  }
157
-
158
155
  /** One row for the panel: status + failure-classification fields (issue #86). */
159
156
  function entrySnapshot(shared, id, record, isStaged) {
160
- const status = shared.mounted.has(id)
161
- ? 'running'
162
- : record.frozen
163
- ? 'frozen'
164
- : record.attempts > 0
165
- ? 'failed'
166
- : 'pending'
167
- const item = {
168
- id,
169
- name: record.name,
170
- attempts: record.attempts,
171
- frozen: record.frozen,
172
- lastError: record.lastError,
173
- lastFailedAt: record.lastFailedAt,
174
- failureType: record.failureType ?? null,
175
- missingDeps: record.missingDeps ?? [],
176
- installHint: record.installHint ?? null,
177
- status,
178
- }
179
- if (!isStaged) item.promotedAt = record.promotedAt
180
- return item
157
+ const status = shared.mounted.has(id)
158
+ ? 'running'
159
+ : record.frozen
160
+ ? 'frozen'
161
+ : record.attempts > 0
162
+ ? 'failed'
163
+ : 'pending';
164
+ const item = {
165
+ id,
166
+ name: record.name,
167
+ attempts: record.attempts,
168
+ frozen: record.frozen,
169
+ lastError: record.lastError,
170
+ lastFailedAt: record.lastFailedAt,
171
+ failureType: record.failureType ?? null,
172
+ missingDeps: record.missingDeps ?? [],
173
+ installHint: record.installHint ?? null,
174
+ status,
175
+ };
176
+ if (!isStaged)
177
+ item.promotedAt = record.promotedAt;
178
+ return item;
181
179
  }
182
-
183
180
  /** Snapshot for the panel (leaf values only). */
184
181
  function snapshot(shared) {
185
- const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true))
186
- const promotedList = Object.entries(shared.state.promoted).map(([id, record]) =>
187
- entrySnapshot(shared, id, record, false),
188
- )
189
- return {
190
- safeMode: shared.state.safeMode,
191
- staged: stagedList,
192
- promoted: promotedList,
193
- events: shared.state.events.slice(-10),
194
- // startup-roster pre-check report (issue #144): empty array = healthy
195
- startupIssues: Array.isArray(shared.startupIssues) ? shared.startupIssues : [],
196
- startupCheckedAt: typeof shared.startupCheckedAt === 'number' ? shared.startupCheckedAt : null,
197
- }
182
+ const stagedList = Object.entries(shared.state.staged).map(([id, record]) => entrySnapshot(shared, id, record, true));
183
+ const promotedList = Object.entries(shared.state.promoted).map(([id, record]) => entrySnapshot(shared, id, record, false));
184
+ return {
185
+ safeMode: shared.state.safeMode,
186
+ staged: stagedList,
187
+ promoted: promotedList,
188
+ events: shared.state.events.slice(-10),
189
+ // startup-roster pre-check report (issue #144): empty array = healthy
190
+ startupIssues: Array.isArray(shared.startupIssues) ? shared.startupIssues : [],
191
+ startupCheckedAt: typeof shared.startupCheckedAt === 'number' ? shared.startupCheckedAt : null,
192
+ };
198
193
  }