Files
dsh_shenxian/dsh-server-docs/04-调整方案/65-功能插件启停与web-provider配置联动.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

8.3 KiB
Raw Blame History

65 · 功能插件启停与 web provider 配置联动

  • 日期:2026-09-12
  • 触发:用户决定 AnySearch 接入改走候选池统一机制("有问题解决问题"),而候选池的启停会打穿 profile 层的 provider 硬绑定
  • 结论一句话:把「插件启停」与「dsh-web 的 provider 选择」从两件互不知情的事,变成一件由平台统一维护的事 —— 启用时写死插件声明的 searchProvider,禁用时整段删除,回到「只有一个可用」的自动选择。
  • 状态:✅ 已部署并生效(服务器 lib/ 2026-09-13 00:13 构建 → 00:15:23 重启即已载入;08:02 随档案 78 部署再载入一次)

TL;DR|结论:候选池「禁用即坏」的根因不是候选池,而是 profile 层那段硬绑定的 provider 覆写 —— 插件一被 pnpm remove,配置就指向了不存在的 provider(CONFIGURED_MISSING,且 dsh 不回落)。 解法:平台在 install/uninstall 后按当前 bundles 重算托管段,让两态都正确。 状态:✅ 代码完成并已用真实数据验证两态(§七),待部署


一、问题

dsh 的 web provider 选择语义(源码实证):

  • 显式配置即硬绑定 —— provider 不在了就报 WEB_PROVIDER_CONFIGURED_MISSING,不回落
  • 未配置时只有「恰好一个可用」才自动选,多个可用直接 WEB_PROVIDER_AMBIGUOUS 报错

而候选池的禁用是 pnpm remove(真卸载),于是:

禁用插件 → bundle 移除 → 插件自带 patch(searchProvider: anysearch)不再加载
        → 但 profile 覆写段仍在,仍写死 searchProvider: anysearch
        → 指向未注册的 provider → CONFIGURED_MISSING(搜索直接坏,且用户修不了)

三种静态写法都有坏格子(详见档案 64 §九.3):写死→禁用即坏;不写→共存即歧义;禁 deepseek→禁用后 0 个可用。

二、方案

把 provider 配置从静态改成随 bundles 动态重算:

状态 profile 托管段 结果
插件已启用 searchProvider: <插件声明值> + fetchProvider: http ✅ 走 AnySearch,本地抓取保留
插件已禁用 整段删除 ✅ 只剩 deepseek 一个可用 → 自动选中

平台策略(本档定):fetchProvider 恒为本地 http,忽略插件对 fetch 的声明 —— 这是「保留本地抓取 + SSRF 防护」这条决策的落地位置。插件若声明了别的 fetch provider,不采纳。

三、实现

位置:src/web/routes/business-plugins.ts

新增 职责
collectWebProviderClaims(dir, bundles) 扫描已启用 bundle 自带的 cordis.patch.yml,只取 - id: web 条目下 config.searchProvider 的声明值(跳过 @deepseek-ai/* 官方骨架;正则行扫描,不引入 YAML 依赖)
syncWebProviderPatch(dir) 幂等重算:先摘掉旧托管段(含历史遗留的 platform: anysearch-search),再按当前 bundles 追加

调用点:install() 与 uninstall() 里紧接 reconcileBundles(dir) 之后 —— 保证「bundles 已定 → 配置随之重算」的因果顺序。

托管段标记(用 marker 包裹,便于幂等替换与人工识别):

# >>> platform: web-provider (managed by business-plugins)
# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。
- id: web
  config:
    searchProvider: anysearch
    fetchProvider: http
# <<< platform: web-provider

与既有机制的关系:

  • 与档案 34 的 profile 快照/还原天然兼容 —— SNAPSHOT_FILES 本就含 cordis.patch.yml,探活失败回滚会把这段一起还原。
  • 与档案 16 的三层归属模型不冲突:这是「功能插件」层内部的实现细节,不新增归属层。

四、为什么不让插件自己声明得更明确(备选方案否决记录)

方案 否决理由
插件 package.json 加 dsh.webProviders 字段 第三方插件(AnySearch)不会按平台的自定义约定声明,等于白设
平台代码里硬编码插件名 → provider 映射 每接一个 provider 插件就要改平台代码;且与「插件自带 patch 已声明」的事实重复
读插件自带 patch 推导(采用) 零配置、通用、自动跟随插件升级

五、待办与迁移

  1. 部署:npm run build → scp lib/ → systemctl restart dshs(会中断在线用户,R8 需先知会);
  2. 投放候选池:把 anysearch-dsh-0.1.4.tgz 落入 <dataRoot>/business-plugins/ + 写 business_plugins 行(正规入口是门户 #/plugins 上传,仅 admin);
  3. 迁移 admin:移除 scripts/ensure-anysearch-admin.cjs 写的手工段(platform: anysearch-search),改由平台托管段接管 —— 该脚本后续仅保留「铺候选池」职责或整体退役;
  4. 验证两态:启用态(searchProvider: anysearch)与禁用态(托管段消失 → 自动选 deepseek)各跑一次 --dump-config。

六、红线遵守

  • R1/R2:只改平台自有代码(src/web/routes/),不碰官方 dsh 主程序与缓存。
  • R7:改动仅 1 个源文件(+ 2 处调用);不批量改写、不 git add -A。
  • R8:重启服务前先告知影响并取得确认。

七、实施与验证(2026-09-12)

7.1 代码

src/web/routes/business-plugins.ts 单文件改动:

位置 改动
模块级(导出) 新增 WEB_PROVIDER_OPEN/CLOSE/LEGACY + collectWebProviderClaims() + syncWebProviderPatch()
install() / uninstall() 在 reconcileBundles(dir) 之后各加一行 syncWebProviderPatch(dir)

函数刻意放在模块级并导出(不依赖 app):一是便于被真实产物直接验证,二是为后续加单测留口子。

自检:npm run typecheck exit 0 · npm run build exit 0。

7.2 真实数据验证(未用生产文件)

方法:把编译产物(lib/web/routes/business-plugins.js)以 business-plugins.new.js 名义放到服务器同目录(相对 import 可解析、不覆盖生产文件),再由一次性脚本在 /tmp 副本上跑 admin profile 的真实数据(真实 package.json、真实 profile patch、真实插件包)。脚本与副本用完即删。

态 输入 期望 实测
A 启用 bundles 含 @anysearch/anysearch-dsh(照抄 admin 现状,patch 里还带着早期手工直铺段) 清掉手工段、写平台托管段 ✅ claims={"search":"anysearch"};手工 anysearch-search 段被移除;写入 web-provider 段(searchProvider: anysearch + fetchProvider: http)
B 禁用 从 deps + bundles 移除该插件、删其 node_modules 托管段应整段消失 ✅ claims={"search":null};同步后文件里既无 web-provider 也无 anysearch-search
C 幂等 对态 A 再跑一次 逐字不变 ✅ true
D 无残留 检查态 B 产物 不含任一托管段 ✅ 两个都 false

结论:两态都正确 —— 启用时走 AnySearch 且保留本地抓取;禁用时自动回到 DeepSeek,没有 CONFIGURED_MISSING 坏状态。迁移也顺带完成(手工段被自动清除)。

7.3 待办

  1. 部署:lib/ → 服务器 + systemctl restart dshs(R8:中断在线用户,待确认窗口);
  2. 投放候选池:tgz 落 <dataRoot>/business-plugins/ + 写 business_plugins 行(正规入口 = 门户 #/plugins 上传);
  3. 退役 scripts/ensure-anysearch-admin.cjs 的手工覆写职责 → ✅ 2026-09-13 08:0x 已完成:脚本头加硬拦(默认 exit 2,仅 DSH_ALLOW_RETIRED_ANYSEARCH=1 可放行),保留为「考古/回滚」用途、不再日常可跑;备份 ensure-anysearch-admin.cjs.bak-retire-20260913;实测默认拒绝 rc=2、显式放行可干跑、node --check 语法 OK。原条目 —— 由平台托管段接管后,该脚本只应保留「铺候选池」或整体退役(否则两段并存、后者覆盖前者,会产生难以察觉的配置漂移);
  4. 端到端:admin 在「功能管理」里启用 → 搜一次;再禁用 → 搜一次(应回落到 DeepSeek)。