# 功能打包到基础插件 · 分步实施方案(含逐棒验证与避坑) - 日期:2026-09-26 - 上游判定:`交付物/全功能承载判定-底层插件能否承-20260926.md` §3.12(五块能进一个包;**六件只剩三件**)+ `交付物/AI1NET-三类节点结构调整可行性-20260926.md`(**承载两分**) - 客户端侧依据:工作区 `E:/ProgramData/AIProject/dsh-ai1net-desktop`(**只读引用**,⛔ 不改其文件) - 编排方式:技能 `dsh-auto-handoff-chain`(规划棒 ↔ 执行棒交替 · 一棒一件事 · 收尾四件套) - 状态:**规划件** —— 本文不出手改码、不部署;执行由后续棒次照单落地 --- ## §0 定位与四条硬边界 **这件事是什么**:把现在散在两处的功能(`src/supervisor` / `web/routes` / `db` / `net/relay` / `im` + 实例侧)**收敛成一个可分发的基础插件包**,让它成为「用户装官方 DSH + 一个包 = 全套能力」的那个**包**。 **四条硬边界(⛔ 本方案全程遵守)**: | # | 边界 | 为什么 | |---|---|---| | **1** | ⛔ **不重造分发与更新机制** —— 平台侧共享包库 + 版本维度 + 三只只读口**已上机**;客户端侧拉取器已有对接单(`docs/对接单_桌面端接入版本管理与包更新机制_20260926.md`) | 重造 = 两套指纹两套语义,必定打架(见 §4-C1) | | **2** | ⛔ **不改官方 dsh 主程序与缓存**(R2)—— 扩展只走官方插件/注入点 | 官方明写 developer preview「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」⇒ 任何 fork 都会在下次升级时全量返工 | | **3** | ⛔ **不碰「包外面那三件」**(起实例 / 取身份 / 把包装进去) | 判定件 §3.12.5:那三件归**部署方案**与**引导层**,不在本方案的范围内 | | **4** | 🔴 **执行前必须先拿到用户确认**(2026-09-26 13:19 用户明令:「现在只做方案 落地详细步骤和方案做好了 确认后在执行」) | 方案阶段**只出方案件与交接单**;⛔ 任何棒次**落码 / 部署**前必须先交用户确认,⛔ 不许"自动接续"把执行棒带跑(见 §6.0) | --- ## §1 开工前必须先纠正的三条前提 > ⚠️ 这三条我上一轮(只读了平台仓库时)判断有误或不全,**先纠正,否则方案会建在错前提上**。 ### 1.1 ✅ 官方桌面壳**存在**,且插件机制是同一套 | 我上一轮的说法 | 实际 | |---|---| | 「本仓库无 `apps/`、无桌面壳」 | ⚠️ **只在平台仓库里成立** —— 官方壳在**上游**:`deepseek-ai/deepseek-harness` → **`apps/desktop`(0.1.6-alpha.1)+ `apps/desktop-host`**,`private: true`、未发 npm ⇒ **按官方源码直接构建,不 fork、零 patch** | **实测结论**(客户端线 `docs/S6_官方二次开发能力与桌面端扩展点实测_20260921.md`): > **官方明确支持二次开发,且是"一等公民"级设计**;载体 = **插件(组合包 bundle)+ patch + profile**;**桌面端不是另一套,用的是同一套机制**。 > 官方文档原文:「**独立插件窗口**获得结构化的**列出、安装、移除、更新和更新检查**操作」;「GUI 插件修改…使用相同的 staging、健康检查、激活与 rollback 路径」。 ⛔ 我上一轮「桌面/服务器两条壳要分别实现」的说法**要修正为**:**壳是两条,插件机制是一条**。 ### 1.2 ⚠️ 桌面端**能跑**平台,但**没有多租户隔离** —— 不是"跑不了" | 我上一轮的说法 | 实际(客户端线 `CODEBUDDY.md §9` 原文) | |---|---| | 「`setpriv`/`systemd-run` 是 Linux 专有 ⇒ Windows 上这些代码路径不执行」 | ⚠️ **那条链是 `account` 隔离档的依赖;客户端线走的是 `soft` 档** ⇒ `setpriv`/`systemd-run` 根本不在这条路上 | | 「桌面机不能当承载」 | ✅ **结论对,但理由要换** ⇒ 见下 | 🔴 **实测原文两条**: 1. 「**平台启动实例的方式在 Windows 上不成立**:裸名 `spawn('npm')` → `ENOENT`(全局包是 `.cmd` 垫片);显式 `.cmd` → `EINVAL`;**必须走 `cmd.exe /c` 或 `shell: true`**(`orchestrator.ts:663-665` 现为不带 shell)」 ⇒ 🔴 **这是全案唯一必须改平台代码的地方**,也是客户端线的 **M0 验收点**。 2. 「**多人形态开关是零代码的(只配域名)**,但**隔离档仍是 `soft` ⇒ 用户之间无隔离**」 ⇒ 🔴 **准确说法**:**Windows 上能跑、能起实例;但 `soft` 档下用户之间没有隔离** ⇒ 所以**桌面机只能当"主权承载"(自己的租户),不能当"公共承载"(别人的租户)** —— 与 `AI1NET-三类节点结构调整可行性` 的判定**同向,但现在有了实证理由**。 ### 1.3 ⭐ 桌面端**没有 `webServer`** —— 这是"一个包两端跑"的真正门槛 > 🔴 **2026-09-28 加注(M6 归并的硬前提)**:本节不只是"一个包两端跑"的门槛 —— 它同样是 **M6「设备接入」归并的硬前提**。 > 依据(实测):垫片现状是 `inject = ['connection', 'webServer']`(靠 `webServer.port` 拿本机口,`dsh-client/packages/device-shim/lib/index.js:61`);而 ai1net 包要求 `inject = ['connection']`(`dsh-plugin-ai1net/lib/index.js:102`),且**包内门禁 ①c 逐字断言这一行**、并**逐行**断言代码里零 `webServer` 引用(`dsh-plugin-ai1net/scripts/build.mjs:123` 附近)。 > ⇒ 归并**必须先去掉 `webServer` 依赖**:端口改**包内配置常量** + **动态发现**(与 §5.2 的上游改造是同一处改动,正好合并)。⛔ 不改 ⇒ 门禁红 + 桌面端逐字复现本节的 `pending` 现象。 **实测**(`S6 §3`,出处 `apps/desktop-host/src/index.ts:326-343` + `dsh-client-connection/lib/index.js:547-601`): | 扩展点 | Web 组合 | **桌面组合(实测)** | |---|---|---| | `webServer` 具名路由 / fallback | ✅ 有(激活即 listen) | ❌ **服务根本不存在**(壳内 host 走 fd 管道、不 listen) | | `/api/*` 服务端逻辑 | 经 webServer | ✅ **`ctx.connection.fetch.register({path, methods, fetch})`** | | ⚠️ 路径匹配 | 支持 `exact` / `prefix` | 🔴 **只支持精确路径,⛔ 无前缀匹配** | | 页面级路由(新增一张页) | ✅ `register({kind:'exact'\|'prefix'})` | ❌ **无页面路由**(`/api` → connection,其余全归 SPA dist) | | 浏览器端插件(client bundle) | ✅ `dsh.client` + `/plugins/*` | ✅ **同一套** | | index 注入 | `webserver/index-inject` | ✅ 同一事件(`desktop-host/src/index.ts:191`) | ⇒ 🔴 **所以"一个包"不是"写一遍就行",而是"必须只用两端都存在的扩展点子集"**: ``` 可用交集 = { connection.fetch.register(精确路径) · dsh.client(浏览器端) · index 注入 } ⛔ 禁用 = { webServer.register · 前缀路由 · 页面级路由 } ``` --- ## §2 目标形态 ``` ┌──────────────── 一个发行包(组合包 bundle)────────────────┐ │ 模块① 多租户界面与请求 模块② 模型管理 │ │ 模块③ 技能插件管理 模块④ 覆盖网络界面 │ │ 模块⑤ IM 插件侧 (+ client bundle:五个卡片) │ └───────────────────────────┬───────────────────────────────┘ 同一份代码,两处加载(同一个 pluginId、同一个库) ┌───────────────────────────┴───────────────────────────────┐ ▼ ▼ 【实例内 / 桌面壳内】 【平台进程】 · 全部业务逻辑 · 只接受"请求" · 依赖 = 扩展点交集 · 要写平台库的动作在这里 · 桌面端 = 官方壳 + 本包;服务器端 = 官方 dsh + 本包 · ⚠️ 平台不是"实例"⇒ 装不了插件 (判定件 §3.12.3) ``` **一个包、一个 pluginId、一个库**(`dshs_pl_`),库内表按模块**分段命名**。 --- ## §3 分步实施方案 > 每步格式:**目标 / 前置 / 步骤 / 🔴 验证(可复现)/ ⚠️ 坑 / 回滚 / 完成判据**。 > 🔴 **验证一律要"能变红"** —— 不通过"负控"(故意改坏一处必须报红)的判据,不算判据。 ### S0 · 立验证地基(**先立尺,再动手**) **目标**:让 S1 之后的每一步都有**一键可复现**的判据。⛔ 这一步不做,后面所有"验证"都是自说自话。 **前置**:无。**不需要拍板、不需要改任何代码。** **步骤**: 1. **取基线**(两端各一条命令,结果落文件): - 平台侧:共享层包数与指纹(现读数为 **4 个包**,`dsh-plugin-mcn-suite` = `0.5.1` / `27f6c6d7…` / 659 文件 / 2,018,751 B) - 客户端侧:本机 profile 的 bundle 清单(现为 **4 个 bundle、无 `@dsh-local/*`**) 2. **建「插件四判据」冒烟脚本**(用**已有插件**当靶子,⛔ 不用新包):装配 ✓ / 运行 ✓ / 取数 ✓ / 回调 ✓ 3. **建「浏览器级判据」脚本**(客户端 UI 必须有这一关) 4. **做负控**:故意把靶子改坏一处 ⇒ **脚本必须报红**;改回 ⇒ 必须报绿 **🔴 验证**: - 同一脚本**连跑两次**,结果一致(排除偶发) - **负控通过**:改坏 ⇒ 红|改回 ⇒ 绿(这一条是 S0 的**唯一**完成判据) **⚠️ 坑**: - ⚠️ **`dump-config` 验不了运行时挂载**(官方 "without booting" ⇒ 不跑 `apply()`)⇒ 证据**必须取活体 boot**(`$B/home-adapt/logs/startup-*.log` 有 fiber 级 outcome) - ⚠️ **启动日志只记失败条目**(插件 `logger.info` 不在其中)⇒ 运行时挂载证据只能取 **RPC** 或 boot 失败面 - 🔴 **`node --check` +「client bundle 可组合」+「boot 本包零报错」三条全绿也证明不了客户端能加载** ⇒ **UI 交付必须有浏览器级判据** **回滚**:删脚本(无副作用)。 **完成判据**:负控红/绿各一次,且连跑两次一致。 --- ### S1 · 定「包规格书」(**不动一行代码**) **目标**:产出一份冷读者能照做的**包规格书**。这是后面所有棒次的共同输入。 **前置**:S0 完成。 **步骤**: 1. **列模块**:五个模块的名称、职责、边界(哪一块的哪一半在平台侧) 2. **逐条列扩展点**,每条必须标注**两端是否都有**: - ⛔ 出现任何 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改成 `connection.fetch.register` + 精确路径** 3. **列全部 API 精确路径**(⛔ 不能靠前缀)—— 桌面端**只认精确路径** 4. **定包名与库名**:包名 ⇒ 库名 `dshs_pl_`,**长度必须 ≤ 48**、`[a-z][a-z0-9_]{0,40}` 5. **定表名分段方案**(库内表加 `p__` 前缀,模块靠后半段区分) 6. **定迁移入口**:只留**一个**入口、按模块分步执行(治"一个包一个库 ⇒ 一次迁移牵动全部") **🔴 验证**: - **冷读者测试**:把规格书给一个不参与本方案的会话读,问它「能不能照做」—— 不能 ⇒ 补 - **逐条扩展点对表**:与 §1.3 的表逐条比对,**⛔ 零条落在"桌面不存在"那一列** - **库名长度硬校验**:用正则跑一遍(⛔ 别目测) **⚠️ 坑**:见 §4-B1 / §4-D2。**回滚**:无(纯文档)。 **完成判据**:规格书里**没有一条扩展点只在 Web 端存在**。 --- ### S2 · 空包两端落地(**最小可运行骨架**) **目标**:一个**最小包**(一个 client 卡片 + 一条 host 路由),**两端都能装、都能出界面**。 **前置**:S1 规格书完成。 **步骤**: 1. 按官方三步做包:`apply(ctx)` 模块 → `package.json` 写 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` → `dsh plugin add` 2. host 半:`ctx.connection.fetch.register({path:'/api//ping', methods:['GET'], fetch})`,**`inject = ['connection']` 必写** 3. client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`) 4. 两端各起一次,看界面 **🔴 验证(四层,缺一不算过)**: | 层 | 怎么验 | 过了说明什么 | |---|---|---| | 语法 | `node --check` | ⚠️ **只说明语法** | | 装进 profile | 本机 profile 里出现该包 | 装配通 | | **活体 boot** | `startup-*.log` 有 fiber 级 outcome | 挂载通 | | **浏览器级** | 真壳里出现卡片、**JS 错误 0**、无 `Failed to load plugins` | ✅ **唯一能证明 UI 真的能用** | **⚠️ 坑**:§4-A1(lazy-CJS)· §4-A2(`inject` 必须声明)· §4-A3(`maxDepth` 不可省)· §4-A4(静态注入是坏的)· §4-E2(改插件后必重启壳)。 **回滚**:从 profile 移除该包(⛔ 目录用 `mv`,不删)。 **完成判据**:**两端**都出现卡片;浏览器控制台 JS 错误 = 0。 --- ### S3 · 数据面(一个包一个库) **目标**:包能建自己的库、跑迁移、被对账。 **前置**:S2 通过。 **步骤**: 1. 声明数据面 ⇒ **平台侧建库** `dshs_pl_`(⛔ 顺序:**先建库再投放**) 2. 迁移:模块分段、单入口分步执行 3. 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账 4. **改声明后回读 yaml 实际值**(⛔ 不回读 = 会漏落) **🔴 验证**: - 建库 → 迁移 → 对账**三轮全绿**;库名匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$` - **负控**:改一处声明**不回读** ⇒ 必须能查出 yaml 与声明不一致(证明"回读"这一步真的在起作用) **⚠️ 坑**:§4-D1 · §4-D2 · §4-D3 · §4-D4。 **回滚**:库用 `mv` 式子改名冻结,⛔ 不 `DROP`。 **完成判据**:三个模块的表都在同一个库里且**能单独回滚**。 --- ### S4 · 接进既有的分发与更新机制(⛔ **一行都别重造**) **目标**:包能被平台**对账 + 增量拉取 + 原子换上**,两端指纹逐字一致。 **前置**:S3 通过。⚠️ **本步依赖一个待拍板项**(§6-P1:平台侧只读三口增认「设备身份」)—— 未拍板时**只做能做的**(手工主机密钥干跑 happy path)。 **步骤**: 1. **照抄权威实现**:`src/worker/agent.ts` 的 `syncSharedLayerOnce()` + `pullOnePack()`(纯 Node、无框架依赖) 2. 实现四步:对账 → 取包 → 落地(原子换六动作)→ 回报 3. 接进 profile 装配:依赖写 **`"<包名>": "link:<目录绝对路径>"`** 4. **补 `@deepseek-ai/*` 软链**(共享层里**零个**这些包,它们是宿主提供的 optional peer) 5. 镜像目录绝对路径**与平台侧逐字同形** **🔴 验证**: - **指纹逐字相同**(⛔ 不是"大概一致");对账 `diff` 三个集合全空 - **三条反例判据(⛔ 不验不算完成)**: 1. **清单拿不到**(断网/黑洞)⇒ 本地文件**零改动**(前后指纹一致) 2. **单个包失败** ⇒ 其余包**照常更新**(不是整轮放弃) 3. **`extra`**(本地多出来的包)⇒ 平台只告知,**本地一个文件没被动过** - **装配后**:本机 profile 出现该包 ⇒ 实例页「能力管理(插件/技能)」栏**开始出现** **⚠️ 坑**:§4-C1 – §4-C5(**指纹算法不一致 ⇒ 永远 stale、每轮全量重拉**;对账与下载共用超时;`link:` 目标两机同路径;软链缺失表现为"包在但加载不了")。 **回滚**:**停掉拉取器即回滚**(拉取失败本就不动本地文件 ⇒ 无数据风险)。 **完成判据**:三条反例判据全过 + 指纹逐字相同。 --- ### S5 · 第一块迁移:**IM 插件侧**(选它是因为它有现成模板) **目标**:把 IM 的插件侧并入基础包。 **前置**:S4 通过。 **为什么第一个做它**:判定件 §3.12.3 / §3.12.6 —— **IM 是今天唯一「装配 / 运行 / 取数 / 回调」四面齐全的** ⇒ 它**就是模板**,其余四块照它做。 **步骤**: 1. 照 IM 的接入方式把插件侧搬进包(⛔ **是重写,不是 `git mv`**) 2. 补上残留两条:① 校验"插件是否真在该实例启用" ② 一条实例只该有**一条**常驻长轮询 **🔴 验证**: - IM 四条面(装配/运行/取数/回调)在**新包**里逐条复现 - **回归**:迁移前后,同一房间的消息收发行为一致(⛔ 用同一组输入比对) - **负控**:故意让长轮询起两条 ⇒ 必须能检出 **⚠️ 坑**:§4-B2 · §4-C1。 **回滚**:切回旧实现(两套并存一段时间,⛔ 不删旧的)。 **完成判据**:四条面齐全 + 行为回归一致 + 残留两条已补。 --- ### S6 · 第二块:**技能插件管理**(含"更新-告知 / 自动") **目标**:上传 → 检测 → 建库 → 启用 → 更新,全链进包。 **前置**:S5 通过。 **步骤**: 1. 管理面(上传/检测/建库/启用)进包 2. 「更新」四拆照判定件 §2.1:① 看见新版本 ✅ 包 ② 点更新按钮 ✅ 包(**只到"请求"**)③ 把新包搬进来 → **走 S4 的拉取器**(⛔ 不在包里自己搬)④ 「设置自动处理」→ **执行者必须在包外** 3. package.json 指向 yaml;自增 id ⛔ 不能照搬;UI ≤900px 必须独占 **🔴 验证**: - 全链 dry-run 一遍 - **"看见新版本"的判据**:包只报"有差异",⛔ **不自己下结论**(对齐既有纪律:**「不知道」不是「已知」**) - 「自动处理」开关在包外时,**关掉执行者 ⇒ 行为必须停**(证明执行者确实在包外) **⚠️ 坑**:§4-D1 · §4-E1。 **完成判据**:更新链的**每一步**都能指出"谁在做",且执行者都在包外。 --- ### S7 · 第三块:**模型管理** **目标**:配置 / 凭据 / 共享授权 / 界面进包。 **前置**:S6 通过。 **🔴 验证**: - 共享模型走**管理员逐用户授权**(`users.shared_model_granted`,v11 默认 0)—— 负控:未授权用户必须看不见 - ⚠️ **用户卷在 worker 上时,写 home 必须走 `UserFs`**;负控:直写 `fs` ⇒ 必须能检出"静默空操作" **⚠️ 坑**:§4-D4(`homedir()` 必错)· §4-F3。 **完成判据**:模型配置在**实例内**可见、**平台级凭据⛔不下发到客户端**(R5 客户端专属红线)。 --- ### S8 · 第四块:**覆盖网络界面**(⚠️ 身份与隧道留在包外) **目标**:看哪些区 / 选加入 / 跨区可见性 / 界面进包。**入网凭据与隧道 ⛔ 不进**。 **前置**:S7 通过。⚠️ **并且 F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。 **🔴 验证**: - 界面能显示"有哪些区可加入" —— ⚠️ **⛔ 不可做成公开列表**(拓扑信息即扩大暴露面),改法=**凭邀请码**;负控:无邀请码时必须**看不到任何区** - 跨区可见性跟随「用户 × 区」成员关系(已拍板);负控:无成员关系 ⇒ 不可见 **⚠️ 坑**:§4-F4(`egressCidrs` 语义)· §4-G1。 **完成判据**:包内**零**身份/隧道逻辑。 --- ### S9 · 第五块:**多租户**(包里只做界面 + 请求) **目标**:租户列表 / 配额 / 成员 / 账单界面进包;**"创建隔离实例"这个动作落平台进程**。 **前置**:S8 通过。⚠️ **依赖 §6-P2(平台侧那一半怎么落)拍板**。 **🔴 验证**: - **界面 + 请求**在包内;**动作**在平台 —— 停掉平台侧 ⇒ 界面必须**明确报"做不到"**,⛔ 不许假装成功 - 配额相关:⚠️ 配额 384 MiB / V8 堆**按宿主算** —— 负控:改宿主规格,租户侧看到的数必须跟着变 **⚠️ 坑**:§4-B3(平台不是"实例"⇒ 装不了插件)。 **完成判据**:包里**没有**任何直接写平台库的代码路径。 --- ## §3.10 落地详细步骤(**S0–S9 逐步照做清单**) > §3 说的是「每步要达成**什么**」,本节说的是「**手怎么动**」。 > 🔴 本节仍是**方案**,⛔ 不是执行授权 —— 全部动作**须用户确认后才可开工**(见 §6.0)。 ### 3.10.1 先把「尺子」定死(S0 的产出规格) 后面九步的判据**一律不许临时目测**,只许调 S0 建出来的四把尺子: | 尺子 | 名字 | 用法 | 绿 / 红怎么算 | |---|---|---|---| | 基线 | `v-baseline.sh` | 取两端基线(共享层包数+指纹 / 本机 profile bundle 清单),落文件 | rc=0,且**连跑两次逐字一致** | | 插件四判据 | `v-plugin.sh <装配\|运行\|取数\|回调>` | 拿**已有插件**当靶子,逐面判 | rc=0 绿 / rc≠0 红 | | 浏览器级 | `v-ui.sh` | 真壳起一次:出卡片 + JS 错误 0 + 无 `Failed to load plugins` | rc=0 绿 / rc≠0 红 | | 负控驱动 | `v-neg.sh ` | 改坏靶子一处 ⇒ 断言 `v-plugin.sh` **必须红** ⇒ 改回 ⇒ 断言**必须绿** | 两个断言都成立才 rc=0 | 🔴 **三条约定先立,否则后面全是扯皮**: 1. **退出码即结论**:绿 = `rc 0`、红 = `rc≠0`。⛔ 不许拿"脚本打印了一行 OK"当绿。 2. **只许测活体**:「不 boot 就出结果」的路子(`dump-config` 类)**测不出运行时挂载** ⇒ 证据必须来自活体 boot 日志的 fiber outcome 或一次真实请求。 3. **落点** = `交付物/验证脚本/`(与方案件同处,已在入库范围);⛔ **不放进文档库 `07-scripts/`**(那里绑宿主 hook,动它影响全平台)。 ### 3.10.2 S0 · 立验证地基 | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S0-1 | 取平台侧基线(包数 + 每包指纹) | 平台侧(**只读**) | `交付物/验证脚本/baseline-平台-<日期>.txt` | 现值应为 **4 包** | | S0-2 | 取客户端侧基线(profile bundle 清单) | 本机 | `baseline-客户端-<日期>.txt` | 现值应为 **4 bundle**、**无 `@dsh-local/*`** | | S0-3 | 写 `v-plugin.sh`(四面判据) | 本机 | 脚本 | 拿**已有插件**跑 ⇒ 四面全绿 | | S0-4 | 写 `v-ui.sh`(浏览器级) | 本机 | 脚本 | 拿已有插件跑 ⇒ 卡片出现、JS 错误 0 | | S0-5 | 写 `v-neg.sh` 并跑负控 | 本机 | 脚本 + 负控记录 | **改坏 ⇒ 红、改回 ⇒ 绿**(各一次) | | S0-6 | 连跑两次对比 | 本机 | 对比记录 | 两次输出**逐字一致** | **失败怎么办**:若 S0-3/S0-4 拿**已有插件**都跑不绿 ⇒ 🔴 **立刻停**,说明基线本身有问题,先修尺子再谈打包(⛔ 不许"先往下走、回头再修")。 ### 3.10.3 S1 · 定包规格书 | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S1-1 | 列五个模块的名称/职责/边界(标明哪一块的哪一半在平台侧) | 文档 | 规格书 §1 | 冷读者能说出"这块归谁" | | S1-2 | 逐条列扩展点,**每条标"两端是否都有"** | 文档 | 规格书 §2 | 与 §1.3 表**逐条**比对 | | S1-3 | 列全部 API **精确路径** | 文档 | 规格书 §3 | ⛔ 零条前缀 | | S1-4 | 定包名 → 库名,**跑正则** | 文档 | 规格书 §4 | 匹配 `^dshs_pl_[a-z][a-z0-9_]{0,40}$` 且**总长 ≤48** | | S1-5 | 定表名分段(`p__` + 模块后半段) | 文档 | 规格书 §5 | 同库内任意两表不重名 | | S1-6 | 定**唯一**迁移入口(按模块分步) | 文档 | 规格书 §6 | 能指出"只回滚某模块"走哪一步 | **失败怎么办**:一旦出现 `webServer` / 前缀路由 / 页面级路由 ⇒ **当场改掉**,⛔ 不许"先留着后面再说"。 ### 3.10.4 S2 · 空包两端落地 | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S2-1 | 按官方三步建最小包(`apply(ctx)` → `package.json` 写 bundle patch → `dsh plugin add`) | 本机仓库 | 包骨架 | `v-plugin.sh 装配` 绿 | | S2-2 | host 半:注册**一条**精确路径路由,**`inject` 必写** | 本机仓库 | 路由代码 | `v-plugin.sh 取数` 绿 | | S2-3 | client 半:`dsh.client` + **lazy-CJS 封包**(⛔ 零顶层 `import`/`export`/`require`) | 本机仓库 | 卡片代码 | 封包契约门禁过 | | S2-4 | **两端各起一次** | 平台侧 + 本机壳 | 截图 / 日志 | `v-ui.sh` 绿(**两端各一次**) | **失败怎么办**:卡片不出现 ↦ §4-A1|路由静默 404 ↦ §4-A2(`inject` 没声明)|启动报错 ↦ §4-A3/A4。 ### 3.10.5 S3 · 数据面 | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S3-1 | 声明数据面 | 本机仓库 | 声明文件 | **回读 yaml 实际值**并与声明一致 | | S3-2 | **先建库** `dshs_pl_`(⛔ 不许先投放) | 平台侧 | 库 | 库名匹配正则、总长 ≤48 | | S3-3 | 迁移:**单入口**、按模块分步 | 平台侧 | 表 | 三模块的表都在同一库 | | S3-4 | 对账:`pg_database` 的 `dshs_pl_*` × 控制面台账 | 平台侧 | 对账输出 | 两集合一致 | | S3-5 | **负控**:故意改一处声明且**不回读** | 本机 | 负控记录 | 必须能查出"yaml 与声明不一致" | **失败怎么办**:库没建上 ↦ §4-D5(顺序反了)|声明改了没落 ↦ §4-D3(没回读)。 ### 3.10.6 S4 · 接进既有分发机制 | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S4-1 | **照抄**权威实现(`syncSharedLayerOnce()` / `pullOnePack()`,纯 Node) | 本机仓库 | 拉取器 | 与权威实现**逐段对得上** | | S4-2 | 实现四步:对账 → 取包 → 落地(**原子换六动作**)→ 回报 | 本机仓库 | 拉取器 | happy path 跑通 | | S4-3 | 依赖写 `link:<目录绝对路径>` | profile | profile 依赖 | 两端路径**逐字同形** | | S4-4 | 补 `@deepseek-ai/*` 软链 | 客户端侧 | 软链 | 缺软链的现象="包在但加载不了" | | S4-5 | **三条反例判据**:清单拿不到 / 单包失败 / `extra` | 本机 | 三条记录 | ⛔ **不验不算完成** | | S4-6 | 装进 profile 后看实例页能力栏 | 两端 | 截图 | 能力栏**开始出现** | **失败怎么办**:每轮全量重拉 ↦ §4-C1(指纹算法不一致)|超时怪 ↦ §4-C2(对账 20 s 与下载 300 s **必须分开算**)|"不知道"被当成"一致" ↦ §4-C5。 ⚠️ **本步依赖待拍板项 P1** —— 未拍板时只做能做的(用主机密钥干跑 happy path)。 ### 3.10.7 S5 · 第一块迁移(IM 插件侧 · **模板块**) | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S5-1 | 照 IM 接入方式把插件侧**重写**进包(⛔ 不是 `git mv`) | 本机仓库 | 模块⑤ | 四条面(装配/运行/取数/回调)逐条复现 | | S5-2 | 补残留①:校验"插件是否**真在该实例启用**" | 本机仓库 | 代码 | 未启用 ⇒ 必须拒 | | S5-3 | 补残留②:一个实例只允许**一条**常驻长轮询 | 本机仓库 | 代码 | **负控**:故意起两条 ⇒ 必须检出 | | S5-4 | **回归**:迁移前后,同一组输入的消息收发行为一致 | 两端 | 比对记录 | 同输入 ⇒ 同行为 | **失败怎么办**:旧实现**不删**,两套并存一段时间(回滚 = 切回旧的)。 ### 3.10.8 S6 · 第二块(技能插件管理) | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S6-1 | 上传/检测/建库/启用 进包 | 本机仓库 | 模块③ | `v-plugin.sh` 四面绿 | | S6-2 | 「更新」四拆:①②进包(**只到"请求"**);③ 搬包**走 S4 拉取器**;④ 执行者**留包外** | 本机仓库 | 代码 | 能**逐条指出**"谁在做",且执行者在包外 | | S6-3 | `package.json` 指向 yaml;自增 id ⛔ 不照搬;UI ≤900px 独占 | 本机仓库 | 代码 | 窄屏截图 | | S6-4 | **负控**:关掉包外执行者 ⇒ 自动更新行为**必须停** | 本机 | 负控记录 | 行为确实停 | **失败怎么办**:改完没生效 ↦ §4-E1(测试跑的是构建产物,**先 build**)。 ### 3.10.9 S7 · 第三块(模型管理) | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S7-1 | 模型配置/凭据/共享授权/界面 进包 | 本机仓库 | 模块② | `v-plugin.sh` 绿 | | S7-2 | 共享模型走**管理员逐用户授权**(默认未授权) | 本机仓库 | 代码 | **负控**:未授权用户**看不见** | | S7-3 | 写 home 一律走 `UserFs` | 本机仓库 | 代码 | **负控**:直写 `fs` ⇒ 能检出"静默空操作" | | S7-4 | 验"平台级凭据 ⛔ 不下发客户端"(R5 红线) | 两端 | 检查记录 | 客户端侧**零**平台凭据 | **失败怎么办**:`homedir()` 写错 ↦ §4-D4。 ### 3.10.10 S8 · 第四块(覆盖网络界面) | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S8-1 | 界面:看哪些区 / 选加入 / 跨区可见性 | 本机仓库 | 模块④ | `v-ui.sh` 绿 | | S8-2 | ⚠️ **⛔ 不做公开区列表**(拓扑即暴露面)⇒ 改**凭邀请码** | 本机仓库 | 代码 | **负控**:无邀请码 ⇒ **看不到任何区** | | S8-3 | 跨区可见性跟随「用户 × 区」成员关系 | 本机仓库 | 代码 | **负控**:无成员关系 ⇒ 不可见 | | S8-4 | 确认包内**零**身份/隧道逻辑 | 本机仓库 | 检查记录 | 搜不到身份/隧道代码路径 | ⛔ **本步的硬门**:**F1.5(G1 序号 + G6 撤根)必须先有结论** —— 定稿原文「**F1.5 是一切的前置**」。 **失败怎么办**:连不上 ↦ §4-F2(改走 unix socket)|`egressCidrs` 语义 ↦ §4-F3。 ### 3.10.11 S9 · 第五块(多租户) | 编号 | 动作 | 在哪做 | 产物 | 判据 | |---|---|---|---|---| | S9-1 | 租户列表/配额/成员/账单 界面进包 | 本机仓库 | 模块① | `v-ui.sh` 绿 | | S9-2 | **"创建隔离实例"这个动作留平台进程** | 平台侧 | 代码 | 包里搜不到写平台库的路径 | | S9-3 | 验"平台侧停掉 ⇒ 界面必须**明确报做不到**" | 两端 | 负控记录 | ⛔ 不许假装成功 | | S9-4 | 配额按**宿主**算的负控:改宿主规格 ⇒ 租户侧看到的数**跟着变** | 平台侧 | 负控记录 | 确实跟着变 | **失败怎么办**:想"在包里直接写平台库" ↦ §4-B3(平台不是"实例",装不了插件)。 ### 3.10.12 每一步都要"停得住"(治"做一半烂在那里") | 步 | 停下来时的状态 | 会不会留半成品 | |---|---|---| | S0 | 只有几个脚本 | **不留**(零副作用) | | S1 | 只有一份文档 | **不留** | | S2 | 空包可卸 | 卸掉即回原状 | | S3 | 库空着 | 库用**改名冻结**,⛔ 不 `DROP` | | S4 | 停拉取器即停 | 拉取失败**本就不动本地文件** | | S5–S9 | 新旧并存 | 切回旧实现 | ⇒ 🔴 **所以本方案允许"随时叫停"**:任何一步停下来,系统都还是能用的状态。这也是"确认后再执行"能成立的前提。 --- ## §4 避坑总表(**这是本方案的第二主产出**) > 每条:**现象 → 根因 → 后果 → 规避 → 证伪手段**。全部来自两端实测,⛔ 没有一条是推测。 ### A 类 · 客户端 / UI(**最容易"看着是绿的其实没生效"**) | # | 现象 | 根因 | 后果 | 规避 | 证伪手段 | |---|---|---|---|---|---| | **A1** | 卡片根本不出现、页面报 `Failed to load plugins` | 🔴 **官方 client bundle 契约 = lazy-CJS 封包**:形如 `window.__ModuleLoader__.load({id, factory})`,`factory(require)` 内 `require('react')`…,末尾 `exports.apply`/`exports.inject`;**零顶层 `import`/`export`/`require`**。手写 ESM 直交 ⇒ 浏览器按**经典脚本**加载 ⇒ `SyntaxError: Cannot use import statement outside a module` | 🔴 **`node --check` +「可组合」+「boot 零报错」三条全绿也证明不了能加载** | 按 lazy-CJS 封包写;**`scripts/build.mjs` 的封包契约门禁**必须过 | **浏览器级**:真壳里出现卡片 + **JS 错误 0** + 无 `Failed to load plugins` | | **A2** | HTTP 路由**静默 404** | 🔴🔴 **cordis 的服务只能靠 `inject` 拿** —— `ctx.connection` 未在 `inject` 声明 ⇒ `cannot get property "connection" without inject`;`ctx.reflect.get('connection', false)`(官方注释 "without the inject requirement")**返回 undefined** | 只写防御式 `try/catch` ⇒ **路由不挂、静默 404**,看着像"没人调" | `inject = ['connection']` **必写**(官方同类包 `file-upload`/`ui-deliverables` 全如此) | 真发一次请求;⛔ 别只看"没报错" | | **A3** | 挂载抛错 | `maxDepth:'provider-managed'` **不可省** | 整包挂不上 | 照官方样板写 | 活体 boot 有该条 fiber outcome | | **A4** | `ERR_MODULE_NOT_FOUND` | **静态注入是坏的** ⇒ 以**动态**为准 | 包在但加载不了 | 用动态注入 | 活体 boot | | **A5** | 改了却没生效 | 模块级常量**在加载时读**,**不解热更新** | 白改一轮 | **改插件后必须重启壳** | 重启前后各取一次快照对比 | | **A6** | 一个可选插件把壳搞得起不来 | 可选插件在服务缺失时**抛错** ⇒ `plugin tree failed to load: 1 entry did not activate` | 整棵插件树不加载 | 服务缺失时**降级告警,⛔ 不抛错** | 故意注掉一个服务 ⇒ 其余插件仍活 | | **A7** | 页面看着**完全正常**,但一调用全局就炸 | 把 JS/CSS 内联进 `