330 lines
26 KiB
Markdown
330 lines
26 KiB
Markdown
# 跨机可见性状态回流 · 设计件(2026-09-23)
|
||||
|
|
|
|||
|
|
> **线**:插件投放与分库线 · 第 9 棒(规划棒)
|
|||
|
|
> **定位**:**只出设计件** —— 本轮未改任何生产代码、未动服务器、未碰装配协议。设计件 §六 候选不替用户拍板。
|
|||
|
|
> **上游事实来源**:第 8 棒真机取证(`交付物/S5-S6真机E2E验收-20260923.md` §S6-E)+ 本棒现读代码(行号为源码实际行号)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一 问题定义(缺失能力,⛔ 不是代码缺陷)
|
|||
|
|
|
|||
|
|
### 1.1 现象
|
|||
|
|
|
|||
|
|
`GET /api/plugins/mine` 的 `enabled` 字段在**跨机**时恒为 `false`。
|
|||
|
|
|
|||
|
|
**代码事实**(`src/web/routes/business-plugins.ts:1648-1650`):
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
app.get('/api/plugins/mine', { preHandler: requireAuth }, async (request) => {
|
|||
|
|
const user = request.user!
|
|||
|
|
const { bundles } = readProfileManifest(profileDir(user.id)) // ← 读 Manager 本机盘
|
|||
|
|
const enabled = new Set(bundles)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`profileDir()` 拼的是 **Manager 本机的 `userRoot`**(同文件 `:1608`)。集群形态下 profile 树长在**实例所在那台机**上(同文件 `:1592-1595` 已把这条写成注释)⇒ Manager 读的是自己盘上一个**不存在的路径**。
|
|||
|
|
|
|||
|
|
### 1.2 已排除的解释(第 8 棒真机取证)
|
|||
|
|
|
|||
|
|
| 假设 | 反证 |
|
|||
|
|
|---|---|
|
|||
|
|
| enable 本身失败了 | ⛔ 已在 106 上成功:`node_modules/@dsh-local/storyforge` 软链 09:43 建立、可解到 `package.json`、`package.json` 写入 `file:/var/lib/dshs/bundled-plugins/_dsh-local_storyforge` |
|
|||
|
|
| 沙箱没挂共享层 | ⛔ `dsh-100002-9648cef4.scope` 沙箱命令行含 `--ro-bind-try /var/lib/dshs/bundled-plugins`(S1 挂载成立) |
|
|||
|
|
| 装配没走到远端 | ⛔ `RemoteSpawner.applyPlugins` → worker `POST /plugins/apply` 已生效(S3-E ①–⑦ 全绿) |
|
|||
|
|
|
|||
|
|
⇒ **三项全否**。这是**单向下发、无状态回流**的拓扑必然:`POST /api/plugins/mine/apply` 有去路,`GET /api/plugins/mine` 没有回路。
|
|||
|
|
|
|||
|
|
### 1.3 影响面(按严重度)
|
|||
|
|
|
|||
|
|
| # | 影响 | 谁受损 | 严重度 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 1 | **用户自己看到的状态是错的** | 用户 | 🔴 高 —— 已开通的插件仍显"未开通",用户会重复点开通 |
|
|||
|
|
| 2 | **admin 三段式 UI 第 ②③ 段全错** | admin | 🔴 高 —— 第 7 棒 `loadVisibility()` 的「已启用/已停用」两段**数据源就是 `/mine` 的 `enabled`**(`portal.html`)⇒ 跨机用户的这两段会显示成"全部未启用" |
|
|||
|
|
| 3 | `apply` 的"是否全空操作"判定失效 | 平台 | 🟡 中 —— `:1697-1706` 读**本机** profile 快照算 `toEnable`/`toDisable`(跨机时 `enabledSet` 恒空)⇒ 本该 `noop` 直接返回的请求会**真的起一个远端任务**并标 `restarted:true`(`:1833`)。⚠️ **但不会造出错误装配**:装配清单是**全量意图**(`:1709-1711` 原注释:落盘端按全量做**幂等** reconcile,且明写"本机这份读数**不作为装配输入**")⇒ 代价是**无谓任务**,⛔ 不是"重装文件" |
|
|||
|
|
| 4 | 前端切换后状态回跳 | 用户 | 🟡 中 —— `task.enabled` 走的是装配响应(`:1829-1830`,**真值**),但页面刷新后重读 `/mine` 又变回 `false` ⇒ 表现为"启用了但刷新就没了" |
|
|||
|
|
|
|||
|
|
> ⚠️ **第 4 条是本设计件的关键约束**:`apply` 的**响应路径已经是真值**(`last = await app.supervisor.applyPlugins(user.id, [])` 回的是对端读出来的 `bundles`)。缺的**只有读路径**。⇒ 设计目标是"让读路径也拿到真值",⛔ **不是**"把返回值再采一遍"。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二 事实基础(本棒现读代码,⛔ 全部可复核)
|
|||
|
|
|
|||
|
|
| # | 事实 | 位置 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| F1 | **既有 Manager→worker 拉取通路已存在**:`LeasedSpawner.reportHost()` 每次心跳 `GET <agentUrl>/healthz`,并读回 `{ok, instances}` 落 `dsh_hosts.last_heartbeat` | `src/supervisor/leased-spawner.ts:157-173` |
|
|||
|
|
| F2 | 同一函数里**另有一条到 worker 的 `POST` 通路**(`/fence`,带 token,`agentFor(hostId)` 定位) | `leased-spawner.ts:144-155, 175+` |
|
|||
|
|
| F3 | **`/healthz` 是免凭据口** | `src/worker/agent.ts:344`(`if (request.url === '/healthz') return`) |
|
|||
|
|
| F4 | worker 侧已有 `GET /instances`("一次拿回整机",对账用) | `src/worker/agent.ts:368` |
|
|||
|
|
| F5 | **`/mine` 是每请求现算**,无任何缓存层 | `business-plugins.ts:1648-1670` |
|
|||
|
|
| F6 | `apply` 响应路径已是真值(对端 `bundles`) | `business-plugins.ts:1829-1830`;`spawner.ts:25-26` |
|
|||
|
|
| F7 | 归属真相在 Manager 侧 `dsh_instances.host_id`(v7 租约/归属) | `src/db/repo.ts:71-73`;`remote-spawner.ts:224-225` |
|
|||
|
|
| F8 | PG 控制面已有心跳落点 `dsh_hosts.last_heartbeat` | `src/db/repo.ts:865-881`;`schema.ts:319/338` |
|
|||
|
|
| F9 | v15 台账两表 `plugin_datastores` / `plugin_data_audit` 均**控制面表**,按"只有 Manager 能写"分层 | `src/db/schema.ts:660-727` |
|
|||
|
|
| F10 | 装配本身**幂等**(同清单重跑 = 空操作)⇒ 重试安全 | `plugin-assembly.ts`;`remote-spawner.ts:456-458` |
|
|||
|
|
| F11 | **无入站口**:worker 只拨出(覆盖网络线口径),Manager ⛔ 不能等 worker 主动 POST 自己 | 覆盖网络线 §判据 |
|
|||
|
|
| F12 | `readProfileManifest(dir)` 是纯读盘函数,**两台机共用同一实现** | `plugin-assembly.ts:49-62` |
|
|||
|
|
| F13 | worker agent 已有**本地 20 s 定时器**驱动(自愈/对账),`/healthz` 里顺手再跑一次 | `worker/agent.ts:250-252` |
|
|||
|
|
|
|||
|
|
### 🔴 读事实基础得出的三条硬约束
|
|||
|
|
|
|||
|
|
**C1(最容易被忽略)**:F11 + F5 ⇒ **"worker 主动上报"物理上走不通**。worker 只拨出、无入站 ⇒ 任何"manifest 被改时由 worker 推给 Manager"的方案都要**新开一条 worker→Manager 的出站通道**(或让 worker 主动 POST 一个 Manager 上不存在的新口)。
|
|||
|
|
⛔ 判据:F5 说 `/mine` 是现算的 ⇒ 即使推了,也得先有可推的**入站端点**。
|
|||
|
|
|
|||
|
|
**C2**:F1 + F3 ⇒ **"Manager 主动拉"的骨架已经在跑了**:`reportHost()` 每次心跳就 GET 一次 worker。缺的只是"这个响应里没有 manifest"。
|
|||
|
|
⇒ 形态①的增量 = **把一个已有调用的返回值加宽**,⛔ 不是新建链路。
|
|||
|
|
|
|||
|
|
**C3**:F6 ⇒ 读路径必须**与响应路径同源**(都取自对端 profile)。若走形态②,须保证 `apply` 后落台账与 `applyPlugins` 回读**取的是同一份**;取错源会造出"任务说开了、台账说没开"的新矛盾。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三 通路形态(三候选 · 逐条优点 + 缺点)
|
|||
|
|
|
|||
|
|
> ⛔ 本节**不替用户拍板**:三条各有优有劣、客观标准分不出高下 ⇒ 属 §1 边界外第 ⑤/⑦ 类,交用户定(见 §六)。
|
|||
|
|
> ✅ 交付件给出**实现级细节**,任一候选被选中都可直接排执行棒。
|
|||
|
|
|
|||
|
|
### 候选 A · **Manager 主动拉**(复用既有心跳调用)
|
|||
|
|
|
|||
|
|
**做法**:把 `reportHost()` 里那条 `GET /healthz`(F1)的**worker 侧返回值加宽** —— 从 `{ok, instances}` 扩成 `{ok, instances, perUser: {<userId>: {bundles, asOf}}}`。Manager 收到后落台账(§四)。
|
|||
|
|
**改动面**:`worker/agent.ts` 的 `/healthz` 返回体 + `leased-spawner.ts:157-173` 的解析与落库。**零新增网络口、零新增凭据、零新增入站面**。
|
|||
|
|
|
|||
|
|
| 优点 | 缺点 |
|
|||
|
|
|---|---|
|
|||
|
|
| **零新增链路**:F1 已在跑、F3 已免凭据 ⇒ 不动传输层、不动覆盖网络、不动 token | **非事件驱动**:manifest 改了要等下一跳心跳(§五 给 5 s 预算) |
|
|||
|
|
| **不新增入站面**(F11 约束下这一点是**硬优势**:其他两案都要新增监听或凭据) | **响应体随用户数线性增长**:单心跳要带全机用户的 bundles ⇒ 需分页/限深(见 §五-4) |
|
|||
|
|
| **与既有归属判据天然一致**:心跳本来就是"这台机报自己有什么",`host_id` 在多 Manager 联邦下天然只上报给自己的 Manager | **广播式**:即使只有一个用户 `enable`,也要整机重报(浪费,但量级可忽略:见 §五-4 的预算) |
|
|||
|
|
| 失败语义**白捡**:心跳断了 = 这台机不可达 ⇒ 陈旧度标记直接复用(§五-3) | **`/healthz` 语义被拓宽**:它从"存活探测"变成"存活 + 状态",须防"重响应拖慢存活探测"(超时预算 §五-4) |
|
|||
|
|
|
|||
|
|
### 候选 B · **共用台账**(apply 时写、读时查)
|
|||
|
|
|
|||
|
|
**做法**:`apply` 成功后在**控制面新表**(或 `plugin_datastores` 侧加列)落"该 user × 该 plugin × 状态",`/mine` 改为查台账。
|
|||
|
|
**改动面**:新迁移(v16+)+ `apply` 写入点 + `/mine` 读取点(F5 现算改查库)。
|
|||
|
|
|
|||
|
|
| 优点 | 缺点 |
|
|||
|
|
|---|---|
|
|||
|
|
| **事件驱动、零延迟**:写完即读得到,无心跳窗口 | **🔴 引入"第二个真相"**:台账 vs 对端 profile 两处,二者漂移就造出更难查的新缺陷(现状 C3 的教训:`apply` 响应取对端、台账取 Manager ⇒ 必漂) |
|
|||
|
|
| **admin 侧一次查询看全域**,天然支持"分段 UI"与"跨节点汇总" | **写时机难题无解**:`reconcile`(实例启动时对账)发生在 **worker 本机**,Manager 不在场 ⇒ 台账**根本收不到**那次变更;要收就得加回流通路(即回到 A 或 C,⛔ 台账不能单独成立) |
|
|||
|
|
| 与 v15 台账同库,运维面统一 | **多 Manager 联邦下错**:台账落在"当前 Manager"的库里,而 F7 的归属真相在 `dsh_instances.host_id` ⇒ 用户迁机后台账残留(与跨区可见性默认"互不可见"的倾向打架) |
|
|||
|
|
| `/mine` 不再是 O(读盘)(当前每请求一次 `readProfileManifest`,F5) | **不可达时无判据**:台账不知道"这台机现在通不通" ⇒ 降级行为要另配 `dsh_hosts.last_heartbeat`(等于又绕回 A 的数据源) |
|
|||
|
|
|
|||
|
|
> 🔴 **B 的关键否证**:`reconcile` 的驱动点在 worker(F13),Manager 不在场 ⇒ "apply 时写台账"覆盖不了 reconcile 路径 ⇒ **必须配一条回流(= A 或 C)**,台账只能当**缓存**,⛔ 不能当权威。这一条足以说明 B **不是独立候选**,而是 A 或 C 的**内存/落库优化**。
|
|||
|
|
|
|||
|
|
### 候选 C · **worker 上报**(worker 侧新增出站口)
|
|||
|
|
|
|||
|
|
**做法**:worker 在 manifest 变更时主动 `POST` 到 Manager 的新端点(或 Manager 的 `/api/hosts/...`)。
|
|||
|
|
|
|||
|
|
| 优点 | 缺点 |
|
|||
|
|
|---|---|
|
|||
|
|
| **纯事件驱动、最省资源**:只在真变更时发一次 | **🔴 新增 worker→Manager 出站通道**:F11 说 worker 只拨出 ⇒ 要么让它反向拨 Manager(新凭据 + 新信任关系),要么在现有隧道里加反向 RPC ⇒ **等于扩大可见面/攻击面**(§1 红线门禁) |
|
|||
|
|
| 状态天然新鲜(`asOf` = 发出时刻) | **Manager 必须开入站口**:⛔ 与"worker 只拨出"的既有形态冲突,要重开一条被刻意关掉的腿 |
|
|||
|
|
| 缩放到多 worker 更优(各报各的) | **失败语义要另造**:worker 上报失败 ⇒ 谁重试、重试几次、退避怎么算,全是新代码(A 案这些白捡) |
|
|||
|
|
| — | **补历史难**:Manager 重启/新增 worker 时,不知道该向谁要全量 ⇒ 仍要配一条 A 式的拉取 |
|
|||
|
|
|
|||
|
|
### 形态对比(一张表收敛)
|
|||
|
|
|
|||
|
|
| 维度 | A 主动拉 | B 共用台账 | C worker 上报 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 新增网络口 | **0** | 0(但需 A/C 兜 reconcile) | **≥1(含新凭据)** |
|
|||
|
|
| 新增入站面(红线) | **否** | 否 | **是** 🚫 |
|
|||
|
|
| 事件驱动 | 否(心跳周期) | 是 | 是 |
|
|||
|
|
| 覆盖 `reconcile` 路径 | **是** | **否(须配 A/C)** | 是 |
|
|||
|
|
| 与 F7 归属判据一致 | **天然** | 需另维护 | 天然 |
|
|||
|
|
| 与 F11「只拨出」冲突 | **否** | 否 | **是** |
|
|||
|
|
| 可否单独成立 | **是** | **否** | 是 |
|
|||
|
|
| 改动量(估) | 小(2 文件) | 中(迁移 + 2 处) | 大(传输层 + 2 处) |
|
|||
|
|
|
|||
|
|
⇒ **本设计件的技术倾向 = A 为骨架 + B 降级为可选缓存**(B 单独不成立,见上否证);**C 需用户先接受"新开一条 worker→Manager 入站口"这条红线**(§1「扩大可见面」)。
|
|||
|
|
⛔ 但最终选哪条**由用户定**(§六),执行棒 ⛔ 不得自行改档。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四 写入时机(两处,⛔ 必须都覆盖)
|
|||
|
|
|
|||
|
|
> 🔴 只做 `apply` 完成时 ⇒ **漏掉 `reconcile`**(实例启动时对账把 profile 改回来)。两条都要。
|
|||
|
|
|
|||
|
|
| # | 时机 | 触发方 | 为什么必须 | 频率 |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| T1 | **apply 完成后** | Manager(`business-plugins.ts:1829` 那条 `applyPlugins(user.id, [])` 已是真值读取) | 用户点开通/停用后的即时可见 | 每次 apply 1 次 |
|
|||
|
|
| T2 | **实例启动时 reconcile** | **worker 本机**(F13 的 20 s 定时器 + 启动流程) | profile 会被 reconcile 改动(`reconcileBundles` 摘掉不在 deps 的项)⇒ 只信 T1 会漂 | 每次拉启 + 20 s 对账 |
|
|||
|
|
|
|||
|
|
**T1 的落地**:不新增调用 —— 复用 `:1829` 那次回读,把 `last.bundles` **除了**写 `task.enabled` 之外,**同时**落状态(A 案下 = 落 Manager 侧内存缓存;B 案下 = 落台账)。
|
|||
|
|
**T2 的落地**:A 案下**不需要专门做** —— 心跳周期拉取天然覆盖(F1 每次心跳即一次全量对账)。这是 A 相对 B/C 的**实质优势**:`reconcile` 这条路径在 A 案里"白覆盖",在 B 案里"根本覆盖不到"。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五 语义细节(陈旧度 / 降级 / 预算)
|
|||
|
|
|
|||
|
|
### 5-1 `asOf`:**必须带**,⛔ 不是可选项
|
|||
|
|
|
|||
|
|
| 字段 | 语义 | 必填 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `enabled` | 对端 profile 的 `dsh.profile.bundles` 是否含该 id | ✅ |
|
|||
|
|
| `asOf` | **该读数的采集时刻**(epoch ms,与全库时间戳口径一致) | ✅ |
|
|||
|
|
| `stale` | `now - asOf > <阈值>` 的**派生标记**(⇒ 现算,⛔ 不落库,避免"标记本身也陈旧") | ✅(派生) |
|
|||
|
|
| `source` | `'pull' \| 'task'`(来自心跳拉取 / 来自 apply 响应)⇒ 诊断与排障用 | 建议 |
|
|||
|
|
|
|||
|
|
**`stale` 阈值自决 = 3 × 心跳周期**。理由:单跳失败不该立刻报"陈旧"(网络抖动),连续 3 跳丢失才说明这条腿真断了。⚠️ 阈值须**与心跳周期同源计算**,⛔ 不写两个独立常量(旧教训:两套判据必然漂移)。
|
|||
|
|
|
|||
|
|
### 5-2 🔴 `enabled` 的语义澄清(本设计件最容易被误读的一条)
|
|||
|
|
|
|||
|
|
第 8 棒已记:跨机时 `/mine` 的 `enabled` = **本机快照**。本设计件补充**语义分层**,避免后续棒继续混用:
|
|||
|
|
|
|||
|
|
| 层面 | 真值在哪 | 谁权威 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **意图**("用户点了要开") | 控制面台账(`plugin_data_audit` 的 `enable_requested`) | 平台 |
|
|||
|
|
| **事实**("profile 里真的开了") | **对端 profile 的 `bundles`** | 实例所在那台机 |
|
|||
|
|
| **不可用**("这个插件现在能不能开") | `plugin_datastores.state`(v15 台账) | 平台 |
|
|||
|
|
|
|||
|
|
⇒ **`/mine` 的 `enabled` 取"事实",不取"意图"**(现状已是如此,C3 也要求如此)。回流通路只解决"事实从哪读"。
|
|||
|
|
⚠️ 由此得一条硬判据:**失败重试期间 `enabled` 应仍显示旧事实**(⛔ 不显示"意图已提交")—— 否则用户看到 `true` 但实例上没装 = 更糟的假象。
|
|||
|
|
|
|||
|
|
### 5-3 失败重试与不可达降级
|
|||
|
|
|
|||
|
|
| 场景 | 行为 | 判据 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 单跳心跳失败 | **不动读数**,`stale` 现算后可能仍为 `false`(阈值 3 跳) | `now - asOf <= 3×心跳周期` |
|
|||
|
|
| 连续 ≥3 跳失败 | `stale=true` + UI 标注"状态可能滞后" | — |
|
|||
|
|
| 该机 `dsh_hosts.status='down'` | `stale=true` + 保留**最后一次成功读数** | F8 已有 status 列 |
|
|||
|
|
| **从未成功拉过**(新机 / 新用户) | `enabled` 回落**本机快照**(= 当前行为),并标 `source:'pull'` 缺失 ⇒ UI 显"未知" | ⛔ **不许把"未知"显示成"未启用"**(这是当前缺陷的成因,别在新代码里复制它) |
|
|||
|
|
| apply 响应路径失败 | 按现状:`task.status='failed'` + 友好错误(`:1842-1851`)—— **⛔ 本设计件不改这条** | — |
|
|||
|
|
|
|||
|
|
**重试策略自决**:A 案下**不做专门重试** —— 心跳本身就是重试(每周期一次,天然退避到周期粒度)。⛔ 不引入独立重试队列(会造出第三个真相)。
|
|||
|
|
|
|||
|
|
### 5-4 响应体预算(A 案的关键工程约束)
|
|||
|
|
|
|||
|
|
- 单个 userId 的 bundles 列表:实测量级 **≈ 6 项 × ~40 字符**(106 客实例回退后为 4 项)⇒ 单用户 ≈ 0.3 KB。
|
|||
|
|
- 单机 100 用户 ⇒ 单心跳 ≈ **30 KB**。心跳周期量级为秒级(`heartbeatMs`)⇒ 30 KB/跳 × 100 台 worker **在覆盖网络上属可接受**,但**必须设上限**。
|
|||
|
|
- **自决的护栏**:① 响应体硬上限(超限 ⇒ 截断 + 标 `truncated:true`,⛔ 不静默)② 只为**该 Manager 认领的用户**返回(F7 的 `host_id`)⇒ 天然把"全机用户"收窄成"我的用户" ③ 上限与心跳超时分开算(⛔ 不许让状态拉取拖垮存活探测 —— 见 A 案缺点栏)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 六 与既有 v15 台账的关系
|
|||
|
|
|
|||
|
|
| 台账 | 现状职责 | 本设计件要不要动 | 理由 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `plugin_datastores`(v15) | 插件**数据面**状态(库名/state/schema_version/plan_hash) | **⛔ 不动** | 它是"插件级"(每插件一行),本件是"**用户 × 插件**"(每用户每插件一行)⇒ **粒度不同,塞不进去** |
|
|||
|
|
| `plugin_data_audit`(v15) | DDL/迁移审计流水 | **⛔ 不动**;新路可选追加一类 action | 它是**追加型流水**(`id` 自增、`ts`/`actor`/`plugin_id`/`action`/`detail`)⇒ 若 B 案被选中,可复用为"意图"记录,⛔ 但**权威读数仍在对端 profile**(C3) |
|
|||
|
|
| **要不要新表** | — | **A 案 ⇒ 不要**(内存缓存即可,无常驻必要)|**B 案 ⇒ 要**(新迁移 v16+) | 判据:**A 案下台账只是缓存,缓存不该有持久 schema**;一旦落表就会有人当权威读 ⇒ 正是 F9/C3 要防的 |
|
|||
|
|
|
|||
|
|
🔴 **F9 的一致性要求**:若 B 案被选中,新表**必须落控制面**(与 `plugin_datastores` 同库),因为"用户 × 插件的启停状态"属**跟着用户走**与**归属**之间的灰色地带 —— 按 `CODEBUDDY.md` 的分层判据,**归属/租约类只有 Manager 能写** ⇒ 落控制面;⛔ **不得落 Worker 本地库**(那会造出"用户迁机后台账留在旧机")。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 七 "不做会怎样"(为什么这条不能长期搁置)
|
|||
|
|
|
|||
|
|
| 时间尺度 | 后果 | 可逆性 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **立刻** | 跨机用户的 `/mine` 恒显 `enabled:false`;admin 三段式 UI 第 ②③ 段对跨机用户**全错**(§1.3-2) | ✅ 可逆(纯显示) |
|
|||
|
|
| **短期** | 用户重复点"开通" ⇒ `noop` 判定失效(§1.3-3)⇒ **本该零成本的重复操作会真的起一个远端装配任务**(标 `restarted:true`)。⚠️ 装配清单是全量 + 对端幂等(`:1709-1711`)⇒ **不会重装文件**,代价是**无谓任务与状态抖动**。正确性不受损 | ✅ 可逆(但仍消耗实例稳定性) |
|
|||
|
|
| **中期** | admin **不敢信**启停状态 ⇒ 管理动作失去依据;出故障时无法回答"这个用户到底开了什么" | 🟡 半可逆 |
|
|||
|
|
| **长期** | 本线要扩到**多 Manager 联邦**(`架构设计/覆盖网络-顶层架构全貌.md`)⇒ 每个 Manager 都得独立回答"我这个区的用户开了什么"。**不建回流 ⇒ 每个 Manager 都要各造一套**(联邦下没有全局读权限 ⇒ 更贵) | 🔴 越晚越贵 |
|
|||
|
|
|
|||
|
|
⇒ **结论:应当立项**(不是"可以不做")。**但形态选择是用户的决定**(§六),⛔ 本件不替拍。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 八 落地轮廓(供执行棒直接照做 · 细节随形态选择而变)
|
|||
|
|
|
|||
|
|
> ⚠️ 本节是**实现提示**,⛔ 不是已生效的设计。**执行棒须等用户定形态**(§六)后才开工。
|
|||
|
|
|
|||
|
|
**若选 A**:
|
|||
|
|
1. `worker/agent.ts` 的 `/healthz`(`:354-365`):返回体加 `perUser`,数据源 = `spawner.listUserInstances()` + 各自 `readProfileManifest(profileDir(...)) .bundles`(F12 共用实现)。**保持免凭据**(F3),但**只返回本机实例的 userId**(⛔ 不接受调用方传 userId 列表 —— 那就是开任意读口)。
|
|||
|
|
2. `leased-spawner.ts:157-173` `reportHost()`:解析 `perUser`,落 **Manager 内存 Map**(key = userId)+ 记 `asOf`。⛔ **不落 PG**(§六)。
|
|||
|
|
3. `business-plugins.ts:1648-1670` `/mine`:`enabled` 改从该 Map 读;缺失 ⇒ 回落本机快照 + 标未知(§5-3)。
|
|||
|
|
4. 前端:`/mine` 响应多带 `asOf`/`stale` ⇒ `portal.html` 在三段式 UI 上按 `stale` 显示"可能滞后"。
|
|||
|
|
5. 验收:真机(106 客实例)`enable` → **≤3 跳心跳** 后 47 侧 `/mine` 读到 `enabled:true`;杀掉 106 agent → 连续 3 跳后 `stale:true`。
|
|||
|
|
|
|||
|
|
**若选 B**(⚠️ 须同时配 A 或 C,见 §三否证):新增迁移 v16+ + 控制面表(须带 `user_id`/`plugin_id`/`enabled`/`as_of`/`source`)+ 同步写点。⛔ **B 单独执行必然造出第二个真相**。
|
|||
|
|
|
|||
|
|
**若选 C**:先解决 `§1「扩大可见面」` 红线 —— 新开 worker→Manager 入站口**须用户明确授权**,然后才是传输层实现。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 九 本件明确不做的事(⛔ 防后续棒越界)
|
|||
|
|
|
|||
|
|
1. ⛔ 未碰装配协议(`file:` vs `link:` 仍待拍板 ⇒ `plugin-assembly.ts` 零改动、交接单 `file:` 描述零改动)。
|
|||
|
|
2. ⛔ 未开可见面开关 `DSHS_SHARED_CATALOG_PUBLIC`。
|
|||
|
|
3. ⛔ 未改代码、未动服务器(本棒定位 = 规划棒)。
|
|||
|
|
4. ⛔ 不做跨节点内容分发(需单独立项)。
|
|||
|
|
5. ⛔ 不替用户拍形态(§三 三候选各有优有劣 ⇒ 上抛,见 §十)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 十 ✅ 形态已定稿(2026-09-23 18:3x · 用户拍板)
|
|||
|
|
|
|||
|
|
> **用户原话**:「可以 worker 上报,具体要管理那个用户的时候 manager 针对性的去查」
|
|||
|
|
> ⇒ **拍板结果 = 形态 D**,⛔ 原有的 A / B / C 三候选**全部作废**(§三 保留为决策过程记录,⛔ 不再作为候选)。
|
|||
|
|
|
|||
|
|
### 10-1 形态 D = **按需定向拉(主)+ worker 变更上报(辅·后置)**
|
|||
|
|
|
|||
|
|
用户这句话同时解决了两件事:
|
|||
|
|
|
|||
|
|
| 用户的话 | 解掉的是 | 结论 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 「**可以 worker 上报**」 | §1 红线「扩大可见面」⇒ **用户已授权**新开 worker→Manager 的报告腿 | 上报**允许做**,但按用户语境定位为**辅助**(缓存失效信号),⛔ 不是主数据源 |
|
|||
|
|
| 「具体要管理那个用户的时候 manager **针对性的去查**」 | §三 A 案最大缺点「每次心跳广播全机用户」 | **主路径 = 按需拉单个用户**,⛔ 不广播、⛔ 不周期全量 |
|
|||
|
|
|
|||
|
|
### 10-2 🔴 关键发现:**"针对性去查"的骨架已经存在,不需要新建任何通路**
|
|||
|
|
|
|||
|
|
本棒现读 `src/supervisor/leased-spawner.ts:144-155` 的 `fenceOnAgent()` —— 它已经在做**一模一样的定位链**:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
const inst = await this.db.findUserInstance(userId, 'main') // ① userId → hostId(F7 归属真相)
|
|||
|
|
const target = inst?.hostId == null ? 本机
|
|||
|
|
: (this.options.agentFor?.(inst.hostId) ?? 本机) // ② hostId → {agentUrl, token}
|
|||
|
|
await this.post(`/fence`, { userId, epoch: … }, target) // ③ 带 token 定向 POST 到那台 worker
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
⇒ **形态 D 主路径 = 把 ③ 的目标从 `/fence` 换成一个新的只读端点**(建议 `/profile-bundles`),其余三件(定位、凭据、方位)**全部复用**。
|
|||
|
|
|
|||
|
|
**由此得到的四条硬优势**(相对原 A/B/C):
|
|||
|
|
|
|||
|
|
| 维度 | 形态 D |
|
|||
|
|
|---|---|
|
|||
|
|
| 新增监听端口 / 新凭据体系 | **0** —— 复用 worker agent 既有入站口与既有 token(与 `/fence` 同级权限) |
|
|||
|
|
| 广播浪费 | **0** —— 只查"要管理的那个用户" |
|
|||
|
|
| 覆盖 `reconcile` 路径(原 B 案死结) | **✅ 天然覆盖** —— 按需查的是**对端现算真值**,不是历史快照 |
|
|||
|
|
| 与 F7 归属判据一致 | **✅ 同源** —— 走的就是 `findUserInstance(userId).hostId` |
|
|||
|
|
|
|||
|
|
### 10-3 ⛔ 形态 D 的两条硬约束(写码前必须遵守)
|
|||
|
|
|
|||
|
|
1. 🔴 **新端点必须带 token,⛔ 绝不能挂在免凭据的 `/healthz` 上**。`/healthz` 是免凭据口(F3)⇒ 若让它接受 `userId` 参数,等于**开了一个匿名任意读口**。新端点须与 `/fence` 同级(`post()` 通路带 token)。
|
|||
|
|
2. 🔴 **只返回该 worker 上"确有实例"的 userId**;查不到 ⇒ 返回 `not_here`,⛔ 不返回空 bundles。区分「这台机上没有该用户」与「该用户一个插件都没开」—— 二者**语义完全不同**,混用即复制现有缺陷(§5-3 末行判据)。
|
|||
|
|
|
|||
|
|
### 10-4 落地轮廓(执行棒照此做)
|
|||
|
|
|
|||
|
|
| 步 | 动作 | 文件 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 1 | worker agent 新增只读端点 `POST /profile-bundles`(带 token),入参 `{userId}`,出参 `{found:boolean, bundles:string[], asOf:number}`;数据源 = `readProfileManifest(profileDir(userId))`(F12 共用实现) | `src/worker/agent.ts` |
|
|||
|
|
| 2 | Manager 侧新增 `fetchUserBundles(userId)`:复刻 `fenceOnAgent` 的三步定位链,改为 `post('/profile-bundles', …)` | `src/supervisor/leased-spawner.ts`(或同目录新文件) |
|
|||
|
|
| 3 | `/mine` 改造:**先判实例是否在本机** —— 本机 ⇒ 直接读盘(零网络);非本机 ⇒ 走第 2 步定向查 ⇒ 带 `asOf` / `source` 返回 | `src/web/routes/business-plugins.ts:1648-1670` |
|
|||
|
|
| 4 | 内存缓存(key = userId,带 `asOf`)+ 陈旧度与降级语义按 §5-1 / §5-3 执行 | 同上 |
|
|||
|
|
| 5 | **辅(后置)**:worker manifest 变更时发轻量失效通知 ⇒ Manager 丢对应缓存。⚠️ **无此步也对**(只是每次查多一次往返)⇒ ⛔ 不得因它阻塞 1–4 | 待定 |
|
|||
|
|
|
|||
|
|
### 10-5 验收(真机)
|
|||
|
|
|
|||
|
|
1. 106 上 `enable` 一个插件 ⇒ 47 侧 `/mine` 读到 `enabled:true`(**无需等心跳**)。
|
|||
|
|
2. 停 106 agent ⇒ `/mine` 降级为"未知"(⛔ **不许显示成"未启用"**)。
|
|||
|
|
3. 本机用户的 `/mine` 路径**零网络往返**(性能不回退)。
|
|||
|
|
4. 对不存在的 userId ⇒ 返回 `not_here`,与"零插件"可区分。
|
|||
|
|
|
|||
|
|
> ⛔ **本件定位不变**:本节只是把"待拍板"改成"已定稿",**本棒仍未写一行生产代码**。执行交执行棒。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 附 复核用命令(本件所有代码行号)
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd D:/github/dsh_shenxian
|
|||
|
|
sed -n '1648,1652p' src/web/routes/business-plugins.ts # /mine 读本机 profile
|
|||
|
|
sed -n '157,173p' src/supervisor/leased-spawner.ts # reportHost:既有拉取通路
|
|||
|
|
sed -n '354,365p' src/worker/agent.ts # /healthz 现状返回体
|
|||
|
|
sed -n '343,352p' src/worker/agent.ts # /healthz 免凭据
|
|||
|
|
sed -n '660,727p' src/db/schema.ts # v15 两表(控制面)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**本棒三门复验(基线记录 · 未改代码)**:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
npm run build → tsc 无输出,rc=0
|
|||
|
|
npm test → 449 tests / 447 pass / 0 fail / 2 skip,rc=0
|
|||
|
|
node scripts/check-layering.mjs → ✅ 无新增违规(现存 5 条全在基线内),rc=0
|
|||
|
|
```
|