Files

484 lines
20 KiB
Markdown
Raw Permalink Normal View History

# S5 / S6 真机 E2E 验收 — 部署落地(第 8 棒)
> **线**:插件投放与分库线 | **棒次**:第 8 棒 · 执行棒 | **日期**:2026-09-23
> **执行依据**:`接续入口_插件投放与分库线_20260922.md` §0 最新行 + §5「本轮动作(第 8 棒)」
> **交接单**:`dsh-server-docs/交接单/插件投放与分库线-①共享只读包库与插件数据面.md` §五 S5 / §六
---
## 〇、总判定(先给结论)
| 项 | 判定 | 关键证据 |
|---|---|---|
| 部署到 47 + 106 | ✅ 完成 | 三机 md5 逐字一致 |
| **S5-E ①** v1→v2 升级、存量数据保留 | ✅ **PASS** | 升级前后均 3 行 |
| **S5-E ②** 强行降级 ⇒ 拒绝且库不变 | ✅ **PASS** | `409 plan_blocked` + 列/行未变 |
| **S5-E ③** 同名同版本重传 ⇒ 幂等 | ✅ **PASS** | `http=200`、tgz md5 与源一致 |
| **S5-E ④** 迁移前结构备份落盘 | ✅ **PASS** | 2 个 `.sql`(643 / 1344 字节)、0 字节文件 = 0 |
| **S5-E ⑤** 备份不可写 ⇒ 500 且库不变 | ✅ **PASS** | `500 schema_backup_failed` + `mkdir_failed: EPERM` |
| **S6-E ①** 非 admin `/mine` 条数 = 候选池全量 | ✅ **PASS** | 5 = 5 |
| **S6-E ②** 开关关 ⇒ 404(只记事实) | ✅ **PASS** | `404`(⛔ 未开开关) |
| **S6-E ③** admin 新增 ⇒ 用户刷新即可见 | ✅ **PASS** | 5 → 6 |
| 门户三段式 UI 逻辑自洽 | ✅ **PASS**(逻辑层 7/7) | 互斥 / 完备 / 语义正确 |
| **真机缺陷修复** | 🔴 **发现 1 个真实缺陷并已修** | `pg_dump` env 传递错误 ⇒ 见 §三 |
| 回归三门 | ✅ 全绿 | build rc=0 · 449/447/0/2 · `added: 0` |
**本轮净产出 = 修掉一个"备份机制在生产上根本跑不通"的真实缺陷**(本机测试测不出、只有真机 E2E 能暴露)。
---
## 一、部署与三机一致性
### 1.1 构建
```
$ cd D:/github/dsh_shenxian && npm run build
(无输出 = tsc 通过)
退出码 = 0
```
### 1.2 三机 md5 逐字一致(部署判据)
```
$ md5sum lib/db/plugin-data/datastore.js lib/web/routes/business-plugins.js
```
| 文件 | 本机 | 47 (`/opt/dshs/lib/...`) | 106 (`/opt/dshs-cluster/lib/...`) |
|---|---|---|---|
| `db/plugin-data/datastore.js` | `9fedd77c80812ec47160e9d413c8d0ab` | 同 | 同 |
| `web/routes/business-plugins.js` | `fe456d60c1f827db53fa1a54ba229591` | 同 | 同 |
| `config.js` | 同 | 同 | 同 |
| `web/portal.html` | `c4f012ce9916825935c0d166daafb2c0`(64894 字节) | 同 | — |
```
$ systemctl restart dshs # 47
退出码 = 0
```
### 1.3 v15 账本表仍在(第 6 棒落地的迁移)
```
$ psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -Atc \
"select tablename from pg_tables where schemaname='public' order by 1;"
audit_log business_plugins credential_vault domains dsh_hosts dsh_instances
email_codes folder_plugins messages overlay_devices plugin_data_audit
plugin_datastores room_members rooms schema_migrations sessions users workspaces
退出码 = 0
```
⇒ `plugin_datastores` / `plugin_data_audit` 均在 ⇒ **v15 迁移未丢**。
### 1.4 部署前备份(可回滚)
```
/opt/dsh/backups/lib-s5s6/47-pre-s5s6-20260923-085319.tgz
/opt/dsh/backups/lib-s5s6/106-pre-s5s6-20260923-085319.tgz
```
---
## 二、S5-E ①–⑤ 真机 E2E
**探针插件**:`@dsh-local/s5probe`,表 `p_s5probe_notes`(scope `user`),内核自动注入 `created_at` / `updated_at`。
### 2.1 S5-E ① v1→v2 升级 + 存量数据保留 ⇒ PASS
```
═══ S5-E 真机 E2E v2(47 · Manager · PG 127.0.0.1:15432)═══
── S5-E ① v1 腿 ──
上传 v1 http=200 ver=1.0.0
建库 http=200 created=None
预演 v1 http=200 planHash=ebd541642cd3e9eb…
迁移 v1(建表) http=200
v1 列 user_id:text, created_at:bigint, updated_at:bigint, title:text, body:text
v1 表清单 p_meta_schema, p_s5probe_notes
写 3 行(直连插件库 · 数据由插件负责 · 补内核审计列)
INSERT 0 3
▶ rows_before_upgrade = 3 (期望 3)
── S5-E ① v2 腿(升级 · 只增不减)──
上传 v2 http=200 ver=2.0.0
预演 v2 http=200 items=['add_column'] forbidden=0
迁移 v2(加列) http=200
v2 列 user_id:text, created_at:bigint, updated_at:bigint, title:text, body:text, pinned:boolean
▶ rows_after_upgrade = 3 (期望 3 —— 存量数据原样保留)
① 升级后行数仍在 : 3 (期望 3) ⇒ PASS
退出码 = 0
```
### 2.2 S5-E ② 强行降级 ⇒ 409 且库未变 ⇒ PASS
```
── ② 强行降级(装回 v1 · schemaVersion 1 < 库现 2)──
降级前:台账=ready|2 | 列=user_id,created_at,updated_at,title,body,pinned
上传 v1(降级包) http=200 ver=1.0.0
预演(降级) http=200
items = []
forbidden = [{"kind": "drop_column", "target": "p_s5probe_notes.pinned",
"detail": "现状有此列、声明里没有 ⇒ 拒绝(DB-03 §四:不允许删列)"}]
executable= False
state = blocked
迁移(降级) http=409
error = plan_blocked
msg = 迁移违反「只增不减」:声明里出现了删列 / 改类型 / 重命名 / 无默认值非空列。请修正声明后重传。
降级后:列=user_id,created_at,updated_at,title,body,pinned | 行数=3
▶ 判据 ②(库未被改)⇒ PASS
退出码 = 0
```
⇒ **防降级护栏在真机生效**:预演即拦(`executable=false` / `state=blocked`),迁移 409,**列与行数一字未动**。
### 2.3 S5-E ③ 同名同版本重传 ⇒ 幂等 ⇒ PASS
```
重传 v2 http=200 tgz md5=0e69625bb1a1e626…(与源一致)
退出码 = 0
```
### 2.4 S5-E ④ 迁移前结构备份落盘(新判据)⇒ PASS
```
── ④ 迁移前结构备份落盘判据(/opt/dsh/backups/plugin-db/<pkg>/)──
total 16
drwx------ 2 root root 4096 Sep 23 09:13 .
drwx------ 3 root root 4096 Sep 23 09:13 ..
-rw------- 1 root root 643 Sep 23 09:13 v0-2026-09-23T01-13-10-233.sql
-rw------- 1 root root 1344 Sep 23 09:13 v1-2026-09-23T01-13-32-231.sql
▶ .sql 个数 = 2(≥2:v0 首建后 + v1 升级前)
▶ 0 字节文件数 = 0(期望 0)
▶ 判据 ④ ⇒ PASS
台账里的备份审计行:
datastore_migrate_backup_ok x2
退出码 = 0
```
⇒ 命名符合 `<v>-<ts>.sql`,**均为非 0 字节**,台账双行审计。
### 2.5 S5-E ⑤ 反证:备份不可写 ⇒ 500 且库未变 ⇒ PASS
> ⚠️ **诚实记录:本腿第一次尝试(`chmod 000`)FAILED**。
> 原因:平台以 **root** 运行,`chmod 000` **拦不住 root**(实测 `touch` 仍成功)。
> 第一次失败的原始输出(保留):
> ```
> 迁移(备份不可写) http=200 ⇒ FAIL
> ▶ 判据 ⑤(库结构未变)⇒ FAIL
> ```
> ⇒ 改用**只读文件系统**作反证载体(`DSH_PLATFORM_DIR=/sys/dshs-nope`)重跑。
```
═══ S5-E ⑤ 反证(备份根 → 只读文件系统)═══
① 对照:正常备份根 ⇒ 迁移应 200
migrate http=200 ⇒ PASS(对照成立的证明)
② 反证:把平台目录指到 /sys/dshs-nope(只读文件系统 ⇒ root 也写不进)
Environment="DSH_PLATFORM_DIR=/sys/dshs-nope"
active
反证前库列 = user_id,created_at,updated_at,title,body
migrate http=500 ⇒ PASS
error = schema_backup_failed
backupError = mkdir_failed: EPERM: operation not permitted,
mkdir '/sys/dshs-nope/backups/plugin-db/_dsh-local_s5probe'
反证后库列 = user_id,created_at,updated_at,title,body
▶ 判据 ⑤a(500 schema_backup_failed)⇒ PASS
▶ 判据 ⑤b(库结构未变) ⇒ PASS
③ 复原:还原 platform-dirs.conf → daemon-reload → restart
Environment="DSH_PLATFORM_DIR=/opt/dsh"
active
退出码 = 0
```
**环境复原已复验**:
```
Environment="DSH_PLATFORM_DIR=/opt/dsh" ← drop-in 唯一载体
(无 .bak-s5e 残留 ✓)
```
> ⚠️ **踩坑记录**:第一次尝试新建第二个 drop-in `s5e-counterproof.conf` 会被**静默覆盖**(同名键后写者胜),故改为 sed 编辑**唯一载体** `platform-dirs.conf` 并原样还原 —— 与记忆中的「drop-in 才是唯一载体」一致。
---
## 三、🔴 本轮发现的真实缺陷(已修 + 已回归钉住 + 已重部署)
### 3.1 现象
S5-E ④ 在真机上**根本跑不通** —— 结构备份永远失败:
```
pg_dump: error: connection to database "dshs_pl_s5probe" failed:
could not connect to server: No such file or directory
Is the server running locally and accepting connections on
Unix domain socket "/var/run/postgresql/.s.PGSQL.5432"?
```
### 3.2 根因(受控复现确认)
`src/db/plugin-data/datastore.ts` 里 `pgEnvFrom(baseUrl)` 的返回值被**展开在 `execFileSync` 的 options 层**(与 `env:` 同级),而不是**合进 `env`**:
```ts
// ❌ 缺陷写法:PGHOST/PGPORT/PGUSER 变成了"选项名"而非环境变量
execFileSync('pg_dump', args, { ...pgEnvFrom(baseUrl), env: {...}, timeout: 120_000 })
```
⇒ `pg_dump` 子进程**收不到** `PGHOST`/`PGPORT`/`PGUSER` ⇒ 退回默认 **Unix socket `:5432`** ⇒ 47 上 PG 实际在 `127.0.0.1:15432` ⇒ 连接失败。
**本机测试测不出**:本机没有 `pg_dump`,该路径根本不会被走到;只有真机 E2E 能暴露。
### 3.3 修复
```ts
env: pgDumpEnv(baseUrl, password),
timeout: 120_000,
```
```ts
export function pgDumpEnv(baseUrl: string, password: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
PGPASSWORD: password,
PGCONNECT_TIMEOUT: '15',
}
try {
const u = new URL(baseUrl)
if (u.hostname !== '') env.PGHOST = u.hostname
if (u.port !== '') env.PGPORT = u.port
if (u.username !== '') env.PGUSER = decodeURIComponent(u.username)
} catch { /* 连接串不可解析 ⇒ 保留 ...process.env */ }
return env
}
```
**顺带修一处不实文案**:`business-plugins.ts` 里第 4 棒遗留的 `backup: 'not_implemented'` 未随第 7 棒接线更新,响应改为真实值:
```ts
backup: backupPathOrNull === null ? 'skipped_first_create' : 'taken',
backupPath: backupPathOrNull,
```
### 3.4 回归钉住(`test/plugin-data.test.mjs`,+2 用例)
```
$ npm test
⬇ 见 §四
```
> ⚠️ 该用例第一版尝试用 PATH 假 `pg_dump` 桩,**Windows 上失败**(`execFileSync` 无法解析 PATH 里的 `.cmd`/`.bat`:`ENOENT` / `EINVAL`)⇒ 改为**直接断言不变量**(env 必须含四个 PG 变量、且⛔ 不得混进 `stdio`/`timeout`/`cwd`/`encoding` 这些选项名 —— 正是原缺陷的特征)。
### 3.5 重部署 + 复验
修后重构建、重部署到 47/106,`datastore.js` md5 `9fedd77c80812ec47160e9d413c8d0ab` **三机一致**,重启后 ④ 腿备份**成功落盘**(643 / 1344 字节)⇒ 缺陷闭环。
---
## 四、回归三门(改完 → 回归 → 才放锁)
| 门 | 命令 | 原始输出 | 退出码 |
|---|---|---|---|
| 编译 | `npm run build` | 无输出(tsc 过) | **0** |
| 全量测试 | `npm test`(Node 22.22.2) | **449 tests / 447 pass / 0 fail / 2 skipped** | **0** |
| 分层 | `node scripts/check-layering.mjs` | `✅ 无新增违规`(`added: 0`) | **0** |
> 基线 **447 / 445 pass / 0 fail / 2 skipped** ⇒ 本轮 **+2**(新增的 `pgDumpEnv` 不变量用例),**失败数仍为 0**。
---
## 五、S6-E 真机 E2E
### 5.1 S6-E ① 非 admin `/mine` 条数 = 候选池全量 ⇒ PASS
```
── ① 非 admin 用户 GET /api/plugins/mine 条数 = 候选池全量 ──
admin /api/plugins/business http=200
guest /api/plugins/mine http=200
▶ 候选池全量 = 5 | guest 可见 = 5
▶ 判据 ①(条数一致)⇒ PASS
退出码 = 0
```
### 5.2 S6-E ② 开关关 ⇒ 404(🔴 只记事实,⛔ 未开开关)⇒ PASS
```
── ② GET /api/plugins/shared/catalog —— 🔴 只记事实,⛔ 不打开开关 ──
admin http=404
guest http=404
▶ 开关 DSHS_SHARED_CATALOG_PUBLIC 当前值: (未设置 = 默认 false)
▶ 判据 ②(关着 ⇒ 404,⛔ 不是空列表)⇒ PASS
⛔ 本棒未打开该开关(属 §1「扩大可见面」红线门禁,须用户拍板)
退出码 = 0
```
> 🔴 **红线遵守声明**:`DSHS_SHARED_CATALOG_PUBLIC` 属「扩大可见面」红线门禁 ⇒ **本棒全程未开**,仅记录"关着 ⇒ 404"这一事实。
### 5.3 S6-E ③ admin 新增 ⇒ 用户刷新即可见 ⇒ PASS
```
── ③ admin 新增插件 ⇒ 非 admin 刷新即可见(无需任何管理动作)──
新增前 guest 可见 = 5
上传新插件 http=200
ok=True name=@dsh-local/s6newprobe
新增后 guest 可见 = 6 | 含新插件 = True
▶ 判据 ③(刷新即可见 · 无需管理动作)⇒ PASS
▶ 判据 ③b(条数 +1)⇒ PASS
退出码 = 0
```
### 5.4 S6-E ② 深挖:enable 落点(跨机形态真实发现)
> **背景**:客用户(`guest` / uid `100002`)实例**在 106(worker)**,不在 47。
> `/api/plugins/mine` 读的是**平台本机** `profiles`(47 上为空)⇒ 字面判据 `enabled:true` **未闭合**。
**实际 enable 确实成功,且正确落到实例所在机 106**:
```
POST /api/plugins/mine/apply http=200
{"ok":true,"taskId":"023a87d692d07654"}
--- 106 上 guest 的 node_modules 出现 storyforge 软链,且指向共享层 ---
lrwxrwxrwx 1 dsh-eeccbc638afc46bdb663 100002 123 Sep 23 09:43
.../home/profiles/web/node_modules/@dsh-local/storyforge
-> ../.pnpm/@dsh-local+storyforge@file+..+..+..+..+..+bundled-plugins+_dsh-local_storyforge/node_modules/@dsh-local/storyforge
--- 该软链可解到 package.json --- ✓ 可解
--- 106 侧 package.json 已写入 ---
"@dsh-local/storyforge": "file:/var/lib/dshs/bundled-plugins/_dsh-local_storyforge"
▶ 判据(装配真落到实例所在机)⇒ PASS
```
**判定**:**不是代码缺陷,是拓扑形态的必然结果** ——
`/mine` 的 `enabled` 字段取自**平台本机 profile 快照**;当用户实例在 worker 上时,本机快照**必然**反映不了远端装配。
**旁证(沙箱命令行原文,证明共享层已 ro 绑进实例)**:
```
dsh-100002-9648cef4.scope loaded active running /usr/bin/bwrap
... --ro-bind-try /var/lib/dshs/bundled-plugins /var/lib/dshs/bundled-plugins
... -- setpriv --reuid 100002 --regid 100002 /usr/bin/dsh --profile web --host 127.0.0.1 --port 21000
```
⇒ **S1 共享只读层挂载成立**(`--ro-bind-try`),且实例确实在跑。
**该发现的价值**:这是 S3「跨机装配」要解决的真实形态问题 —— **平台侧的"用户可见启停状态"需要一个跨机回传通路**(当前只有单向 apply,没有状态回流)。**建议单独立项**,本轮只记录、不改架构。
---
## 六、门户三段式 UI 观察
> ⚠️ **本机 `agent-browser` 每次调用均挂死**(8 分钟无输出;Chromium 进程起了但 `open` 不返回)—— 属环境限制,按技能指引 fail-fast 处理(已 `agent-browser close` + 清理 chrome 进程)。
> ⇒ 改用**等价判据**:`ui-logic-check.mjs` 用**真实 API 数据**复跑 `portal.html` 里 `loadVisibility()` 的三段派生逻辑。
派生逻辑(与 `portal.html` **逐字一致**):
```js
const shared = (catalog && catalog.plugins) || []
const sharedIds = new Set(shared.map((p) => p.id))
const enabled = mine.filter((p) => p.enabled)
const disabled = mine.filter((p) => !p.enabled && sharedIds.has(p.id))
const unavailable= mine.filter((p) => !p.enabled && !sharedIds.has(p.id))
```
结果:
```
═══ S6 三段式 UI 逻辑评估(真实 API 数据)═══
catalogHttp: 404 | mineHttp: 200
① shared (平台共享只读): 0
② enabled (已启用): 2
③ disabled (已停用): 0
⋯ unavailable (暂不可用): 4
mine 总数: 6
switchOff (catalog===null ⇒ 显示开启提示): true
--- 断言 ---
A. 互斥(两两不相交): true
B. 完备(②+③+⋯ = mine 总数): true 「6 vs 6」
C. ② 全为 enabled:true: true
D. ③ 全部在共享层内(能装): true
E. ⋯ 全部不在共享层内(装不到): true
F. 开关关 ⇒ ③=0(⛔ 不能把池里的都算"用户没开"): true
G. 开关关 ⇒ ⋯ = mine 里未启用的全部: true
═══ 总判定:✅ 三段自洽/互斥/完备 ═══
退出码 = 0
```
**⚠️ 未验项(具名)**:CSS 视觉渲染、tab 点击切换 —— 需真浏览器,本机环境不具备。
---
## 七、清理与复原(零残留复验)
| 对象 | 处置 | 复验 |
|---|---|---|
| 探针库 `dshs_pl_s5probe` | `DROP DATABASE` | `pg_database like '%probe%'` = **0** ✅ |
| 候选池 `@dsh-local/s5probe` / `s6newprobe` | 删行 + 删 tgz | 池子恢复 = **原始 4 个** ✅ |
| 账本 `plugin_datastores` | 删探针行 | `like '%probe%'` = **0** ✅ |
| 审计 `plugin_data_audit` | 删探针行 | `like '%probe%'` = **0** ✅ |
| 47 备份目录 `plugin-db/_dsh-local_s5probe` | 删 | 目录不存在 ✅ |
| 共享层 `bundled-plugins/*probe*` | 删(**保留** `_dsh-local_storyforge` / `dsh-plugin-mcn-suite`) | 恢复 2 项 ✅ |
| `platform-dirs.conf` | sed 改 → 原样还原 | `DSH_PLATFORM_DIR=/opt/dsh` + **无 `.bak-s5e`** ✅ |
| **106 客实例 `storyforge` 启用** | **已回退**(见下) | ✅ |
**106 客实例回退明细**(还原到我介入前的 4 依赖状态):
```
dependencies: ['@dsh-local/business-plugins', '@dsh-local/portal-entry',
'@dsh-local/workspace-scoped-picker', '@softspark/dsh-file-preview']
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app',
'@dsh-local/business-plugins', '@dsh-local/portal-entry',
'@dsh-local/workspace-scoped-picker', '@softspark/dsh-file-preview']
✓ package.json 已无 storyforge
✓ symlink 已除
✓ .pnpm 已除
```
**106 健康复验**:
```
dshs-worker.service = active
dshs-relay.service = active
dsh-100002-9648cef4.scope = active running
```
---
## 八、⛔ 未做的事(具名原因)
1. **装配协议 `file:` vs `link:`** ⇒ `awaiting-user-decision`,**本轮零改动**(⛔ 未碰 `plugin-assembly.ts` 协议语法、⛔ 未改交接单里 `file:` 相关描述)。
2. **可见面开关 `DSHS_SHARED_CATALOG_PUBLIC`** ⇒ 属 **§1「扩大可见面」红线门禁**,**未开**,只记录 404 事实。
3. **跨节点内容分发** ⇒ 无平台侧通路(106 共享层手工同步),**需单独立项**。
4. **S6-E ② 字面判据(`enabled:true`)未闭合** ⇒ 原因是**拓扑形态**(实例在 106)而非代码缺陷;已用 106 侧文件证据闭合。**平台侧用户可见启停状态的跨机回流 = 缺失能力,建议立项。**
5. **门户 UI 的 CSS 渲染 / tab 点击** ⇒ 本机 `agent-browser` 挂死,环境不具备;已用逻辑层等价判据覆盖。
6. ⛔ 未 commit / 未 push / ⛔ 未引新依赖 / ⛔ 无 merge / ⛔ 未跑全库 Glob/Grep。
---
## 九、技术附录
### 9.1 测试资产落点
| 文件 | 用途 |
|---|---|
| `tmp/s5e/pkg-v1/` · `pkg-v2/` | 探针插件声明(v1:`title`/`body`;v2:+`pinned`) |
| `tmp/s5e/v1.tgz` · `v2.tgz` | 476 / 523 字节 |
| `tmp/s5e/driver.sh` · `driver2.sh` · `driver3.sh` · `driver5.sh` · `driver6.sh` | 各腿驱动 |
| `tmp/s5e/ui-logic-check.mjs` | 三段式逻辑评估 |
| `tmp/s5e/*.out` | 全部原始输出(本报告引用的每一段均出自此处) |
### 9.2 关键 API 契约(本轮实测确认)
| 端点 | 方法 | 本轮实测 |
|---|---|---|
| `/api/plugins/business` | GET | 200 · 候选池全量 |
| `/api/plugins/mine` | GET | 200 · 含 `enabled` 字段 |
| `/api/plugins/mine/apply` | POST | 200 · `{ok:true, taskId}`(**异步**) |
| `/api/plugins/shared/catalog` | GET | **404**(开关关) |
| `/datastore/plan` | POST | 200 · 返回 `items` / `forbidden` / `executable` / `state` |
| `/datastore/migrate` | POST | 200 / **409 `plan_blocked`** / **500 `schema_backup_failed`** |
### 9.3 表名/列名实况(订正我的两次误判)
- 候选池表 = **`business_plugins`**(**无** `user_id`,全局池)—— 不是 `user_plugins`。
- 账本列 = `plugin_datastores`(**`state`** / `schema_version` / `plan_hash` / `last_error`) —— **无** `status` 列。
- **不存在** `plugin_tasks` 表 ⇒ 异步任务状态**在内存**,落库不可查。
- 探针表实名 = **`p_s5probe_notes`**(`p_<pluginId>_<tableName>`);内核**强制**注入 `created_at`/`updated_at BIGINT NOT NULL`。
---
**本报告全部数字均出自原始输出文件,未做任何"看起来应该对"的推断。**