@arcaneorion/dsh-model-channel-manager 0.3.13 → 0.3.14
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/README.md +29 -0
- package/package.json +1 -1
- package/src/client.js +70 -20
- package/src/index.js +12 -1
package/README.md
CHANGED
|
@@ -187,6 +187,33 @@ dsh plugin --profile web add @arcaneorion/dsh-model-channel-manager
|
|
|
187
187
|
|
|
188
188
|
host 侧兜底:`rewireRoutes` 发现配置组数 > 实际路由数时 `console.warn` 列出被丢弃的组 ID(此前是静默丢弃——H08:3 组保存、路由只有 1 条,界面仍显示「已保存」)。允许清空全部组(空列表合法)。
|
|
189
189
|
|
|
190
|
+
## 保存时的 revision 冲突(0.3.14:自噪声识别 + 自动重试)
|
|
191
|
+
|
|
192
|
+
**症状**:保存后提示「部分保存:提供商配置 已生效;轮询组配置 失败——轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试」,且要点多次才成功。
|
|
193
|
+
|
|
194
|
+
**根因**(dsh-settings 源码事实):`describe()` 里
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config ?? {}])
|
|
198
|
+
revision = previous.revision + Number(previous.raw !== raw)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**revision 就是「该行 raw config 的 JSON 指纹」**。而本插件的 settings 行同时承载健康投影——宿主每 5s 的 digest 叶写、`runtime`、`testResults` 都会改动该行 raw config,revision 因此不断前进。client 加载时记下的 revision 在几秒内必然过期,保存被 `settings/conflict` 拒绝:**提示里的「其他页面」其实是插件自己写的健康数据**。providers 走的是另一个行(`llm-pi-ai`),没有这种自噪声,所以它总是先成功——这正是「部分保存」的原因。
|
|
202
|
+
|
|
203
|
+
**修法**:
|
|
204
|
+
|
|
205
|
+
- client:写入前**重新读取 revision**(消除过期);仍冲突时比较远端值与我们加载时的基线
|
|
206
|
+
(`groups`+`providerOrder` / `providers`,用键序无关的规范 JSON 比较)——
|
|
207
|
+
**远端没变 = 自噪声 → 用新 revision 重试(最多 3 次);远端真的变了 → 才报冲突**。
|
|
208
|
+
既消除误报,也保留真正的多页面冲突保护(审计 F05 的语义)。
|
|
209
|
+
- host:`digest` 内容未变时不写(60s 心跳保底),从源头减少 revision 噪声。
|
|
210
|
+
|
|
211
|
+
回归:`tests/revision-conflict.test.cjs`。
|
|
212
|
+
|
|
213
|
+
> 真正的根治是把健康/任务通道搬出 settings 行(审计建议的 Remote + storageDomain 路线)——
|
|
214
|
+
> 那时本行 revision 只随用户配置变化,连重试都不需要。当前修法在不改通信架构的前提下
|
|
215
|
+
> 同时消除了症状与误报。
|
|
216
|
+
|
|
190
217
|
## 密钥写入(凭据引用虚拟化)
|
|
191
218
|
|
|
192
219
|
- 面板主视图只出现「API Key」输入框:**粘贴或输入后失焦即自动写入** DSH 凭据存储(`~/.dsh/.credentials.yaml`,0600,write-only 读不回),无手动按钮;清空输入框不会删除已存 key。上游 llm-pi-ai 的供应商 profile 只有 `apiKeyEnv` 一个密钥字段(凭据引用名),不存在内联 key 的选项——secrets 不进 settings.yaml、不随 `settings.describe` 下发,是有意的安全设计。
|
|
@@ -316,6 +343,8 @@ react 经 `require('react')`;样式用 `ctx.effect` 自管理;`dsh.client: {
|
|
|
316
343
|
31. **超时守卫 dispose 时必须 settle promise(0.3.13,审计 R3)**:`timed()` 的 guard 若在 fiber dispose 时只 `clearTimeout`,promise 永不 reject——`await Promise.race([inner.next(), guard.promise])` 就永久挂住,在飞测试既不结束也不写终态,客户端只能等到 66s 假超时(表现与「host 未处理」一模一样)。**规则:任何挂在 `ctx.effect` 上的定时器,dispose 时不仅要清定时器,还要让等待它的 promise settle**(这里 reject `ABORTED`),否则卸载路径会留下永久悬挂的 await。配套:新进程启动时清扫上次遗留的 `running` 条目(补 `ABORTED` 终态)——进程内在飞任务都有 fiber 生命周期,重启后见到的 running 一定是遗留的。回归:`tests/runtime-hardening.test.cjs`。
|
|
317
344
|
32. **静默失败要有出口(0.3.13,审计 R2)**:loader 的 `_commitVolatile` 在 `resolveConfig` 抛错时只 `logger.warn` 后返回 true——document 已落盘、fiber 引用不更新、**不发 `loader/volatile-update`**。而本插件的任务通道只在 volatile-update 里消费请求,于是请求被彻底忽略、客户端 66s 超时,宿主侧却「什么都没发生」。同理 `.loose(true)` 的 schema 兜底会把整棵 volatile 子树换成空对象而不报错(踩坑 28)。**规则:凡是「静默丢弃用户动作」的路径都要有可见出口**——① 未消费的请求打日志(每 nonce 一次);② 周期性兜底重试(5s,复用同一消费判定,每 nonce 一次);③ 关键子树从有到无时告警。回归:`tests/runtime-hardening.test.cjs`。
|
|
318
345
|
|
|
346
|
+
33. **settings 的 revision 是「raw config 的 JSON 指纹」,会被插件自己的健康写入推高(0.3.14)**:`describe()` 里 `revision += raw !== previous.raw`,`raw = JSON.stringify([fiber.uid, schema.toJSON(), entry.options.config])`。本插件的行同时承载健康投影(digest 每 5s、runtime、testResults),所以 client 手里的 revision 几秒内必然过期,保存被 `settings/conflict` 拒绝,提示却是「已被其他页面修改」——**其实「其他页面」就是插件自己**;providers 在另一个行(llm-pi-ai)没有自噪声,于是表现为「部分保存:提供商成功、轮询组失败,点多次才成功」。两条教训:① **带 revision 的写入必须「写入前重读」**,加载时记下的 revision 只在「该行没有其他写入者」时才有效;② 冲突要**分类**——比较远端值与加载基线(键序无关的规范 JSON),远端没变就是自噪声(重试),真的变了才是冲突(报错)。根治方向是把高频数据搬出该行(Remote/storageDomain)。回归:`tests/revision-conflict.test.cjs`。
|
|
347
|
+
|
|
319
348
|
## 0.3.11 修复后的自愈路径(无需手工清库)
|
|
320
349
|
|
|
321
350
|
重启后:schema 接受历史字符串 nonce → `records` 重新可见 → `migrateFrom` 把旧流水**合并去重**进 domain(`my-opencode-go` 桶已有新事件,按 `ts|provider|model` 去重后并入)→ path-ops 真删除 settings 里的 `records` → digest 重算包含历史。日志会打印 `legacy health imported into domain and cleared from settings`。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1435,6 +1435,46 @@ window.__ModuleLoader__.load({
|
|
|
1435
1435
|
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'request failed')
|
|
1436
1436
|
return r && r.value !== undefined ? r.value : r
|
|
1437
1437
|
}
|
|
1438
|
+
// 冲突判定:宿主以 settings/conflict 拒绝(dsh-settings 的 SettingsConflictError)
|
|
1439
|
+
const isConflict = (err) => !!(err && (err.code === 'settings/conflict' || /conflict/i.test(String(err.message || ''))))
|
|
1440
|
+
// 规范 JSON(对象键排序)——用于「远端是否真的改过我们关心的字段」的比较,
|
|
1441
|
+
// 不受键序影响(providers/groups 的对象键序在落盘与投影之间可能不同)
|
|
1442
|
+
const canonicalJson = (v) => {
|
|
1443
|
+
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
|
|
1444
|
+
if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
|
|
1445
|
+
return JSON.stringify(v === undefined ? null : v)
|
|
1446
|
+
}
|
|
1447
|
+
const sameJson = (a, b) => canonicalJson(a) === canonicalJson(b)
|
|
1448
|
+
const readNsDesc = async (ns) => {
|
|
1449
|
+
const resp = await apiRef.settings.describe({})
|
|
1450
|
+
const d = unwrap(resp)
|
|
1451
|
+
return ((d && d.namespaces) || []).find((n) => n && n.ns === ns) || null
|
|
1452
|
+
}
|
|
1453
|
+
// 带 revision 的保存 + 「自噪声冲突」自动重试(0.3.14):
|
|
1454
|
+
//
|
|
1455
|
+
// 为什么必须这样:本插件的 settings 行(model-channel-manager)同时承载健康投影,
|
|
1456
|
+
// 宿主每 5s 的 digest 叶写都会改动该行的 raw config;而 describe() 的 revision
|
|
1457
|
+
// 正是「raw config 的 JSON 指纹」(dsh-settings:revision += raw !== previous.raw)。
|
|
1458
|
+
// 于是 client 手里那份 revision 在几秒内必然过期,保存被 settings/conflict 拒绝——
|
|
1459
|
+
// 提示里的「其他页面修改」其实是插件自己写的健康数据。
|
|
1460
|
+
//
|
|
1461
|
+
// 策略:① 每次写入前取最新 revision(消除过期);② 仍冲突时比较远端值与我们加载时
|
|
1462
|
+
// 的基线:没变 = 纯 revision 噪声 → 用新 revision 重试;真的变了 = 真冲突 → 报错。
|
|
1463
|
+
const saveWithRevisionRetry = async ({ ns, baseline, remoteOf, write, conflictMessage, attempts = 3 }) => {
|
|
1464
|
+
let lastError = null
|
|
1465
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1466
|
+
const desc = await readNsDesc(ns).catch(() => null)
|
|
1467
|
+
const rev = desc && typeof desc.revision === 'number' ? desc.revision : undefined
|
|
1468
|
+
const r = await write(rev)
|
|
1469
|
+
if (!(r && r.ok === false)) return r
|
|
1470
|
+
lastError = r.error
|
|
1471
|
+
if (!isConflict(r.error)) throw new Error((r.error && (r.error.message || r.error)) || (ns + ' save failed'))
|
|
1472
|
+
const now = await readNsDesc(ns).catch(() => null)
|
|
1473
|
+
if (!sameJson(remoteOf(now), baseline)) throw new Error(conflictMessage)
|
|
1474
|
+
// 远端未变:属于插件自身健康写入造成的 revision 噪声 → 取新 revision 重试
|
|
1475
|
+
}
|
|
1476
|
+
throw new Error(conflictMessage.replace(/,请点「刷新」后重试/, '') + '(已自动重试 ' + attempts + ' 次仍未成功)' + (lastError ? ':' + String(lastError.message || lastError) : ''))
|
|
1477
|
+
}
|
|
1438
1478
|
|
|
1439
1479
|
// 按持久化的 providerOrder 重排 providers 的键序:settings-file 的 patchNode
|
|
1440
1480
|
// 对 map 键序盲(拖拽纯重排在文件层是零 diff),顺序由 model-channels ns 里的
|
|
@@ -1654,33 +1694,43 @@ window.__ModuleLoader__.load({
|
|
|
1654
1694
|
for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
|
|
1655
1695
|
for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
|
|
1656
1696
|
if (ops.length === 0) return Promise.resolve()
|
|
1657
|
-
// F05:带 revision
|
|
1658
|
-
//
|
|
1659
|
-
return
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1697
|
+
// F05 + 0.3.14:带 revision 保存;冲突若只是宿主侧 revision 噪声(远端 providers
|
|
1698
|
+
// 与我们加载时一致)则自动重试,真的被别处改过才提示「刷新后重试」。
|
|
1699
|
+
return saveWithRevisionRetry({
|
|
1700
|
+
ns: 'llm-pi-ai',
|
|
1701
|
+
baseline: (state && state.providers) || {},
|
|
1702
|
+
remoteOf: (desc) => (desc && desc.value && desc.value.providers) || {},
|
|
1703
|
+
write: (rev) => apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: rev }).then((resp) => {
|
|
1704
|
+
const r = resp && resp.result ? resp.result : resp
|
|
1705
|
+
return r
|
|
1706
|
+
}),
|
|
1707
|
+
conflictMessage: 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试',
|
|
1666
1708
|
})
|
|
1667
1709
|
})() : Promise.resolve()
|
|
1668
1710
|
// 顺序持久化:patchNode 对 map 键序盲,拖拽顺序写进 providerOrder 数组(同一次 update 落盘);
|
|
1669
1711
|
// 即使 channelsDraft 为空(无轮询组),只要 draft 非空也要写——这是排序的唯二持久化时机
|
|
1670
1712
|
const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
|
|
1671
|
-
const
|
|
1672
|
-
ns: 'model-channels',
|
|
1673
|
-
expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
|
|
1713
|
+
const channelsPatch = Object.assign({
|
|
1674
1714
|
// 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
|
|
1675
1715
|
// (如遗留的 group-1)在下一次保存时自动归一,无需迁移
|
|
1676
|
-
|
|
1677
|
-
})
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1716
|
+
groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) }))
|
|
1717
|
+
}, orderPatch)
|
|
1718
|
+
// 0.3.14:本行同时承载健康投影,宿主每 5s 的 digest 叶写会不断推高它的 revision
|
|
1719
|
+
// (revision = raw config 的 JSON 指纹)——旧实现用加载时的旧 revision,几乎必然
|
|
1720
|
+
// 撞 settings/conflict,于是出现「部分保存:轮询组配置失败……点击多次才能成功」。
|
|
1721
|
+
// 现在每次写入前取最新 revision;冲突时只有远端 groups/providerOrder 真的变了才报冲突。
|
|
1722
|
+
const p2 = saveWithRevisionRetry({
|
|
1723
|
+
ns: 'model-channels',
|
|
1724
|
+
baseline: { groups: channels || [], providerOrder: (state && state.providerOrder) || [] },
|
|
1725
|
+
remoteOf: (desc) => ({
|
|
1726
|
+
groups: (desc && desc.value && desc.value.groups) || [],
|
|
1727
|
+
providerOrder: (desc && desc.value && desc.value.providerOrder) || [],
|
|
1728
|
+
}),
|
|
1729
|
+
write: (rev) => apiRef.settings.update({ ns: 'model-channels', expectedRevision: rev, patch: channelsPatch }).then((resp) => {
|
|
1730
|
+
const r = resp && resp.result ? resp.result : resp
|
|
1731
|
+
return r
|
|
1732
|
+
}),
|
|
1733
|
+
conflictMessage: '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试',
|
|
1684
1734
|
})
|
|
1685
1735
|
// F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
|
|
1686
1736
|
// 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
|
package/src/index.js
CHANGED
|
@@ -1177,6 +1177,8 @@ export function apply(ctx, config) {
|
|
|
1177
1177
|
// digest:写进 settings health 子树的小投影(client 健康页渲染用)。
|
|
1178
1178
|
// 聚合上移 host,client 不再拉原始流水全量 describe——settings 里不再出现 records。
|
|
1179
1179
|
let digestTimer = null;
|
|
1180
|
+
let lastDigestJson = null; // 上次落盘的 digest 内容(相同则跳过,减少 revision 噪声)
|
|
1181
|
+
let lastDigestWriteAt = 0;
|
|
1180
1182
|
const DIGEST_INTERVAL = 5000;
|
|
1181
1183
|
const buildDigest = () => {
|
|
1182
1184
|
// 与旧 client HealthPanel 聚合口径一致:total/success/ttft/latency/token(计费口径
|
|
@@ -1220,7 +1222,16 @@ export function apply(ctx, config) {
|
|
|
1220
1222
|
const flushDigest = () => {
|
|
1221
1223
|
digestTimer = null;
|
|
1222
1224
|
if (bus !== null) {
|
|
1223
|
-
|
|
1225
|
+
const digest = buildDigest();
|
|
1226
|
+
// 0.3.14:内容没变就不写。settings 行的 revision 是 raw config 的 JSON 指纹
|
|
1227
|
+
// (dsh-settings describe:revision += raw !== previous.raw),每一次无意义的写
|
|
1228
|
+
// 都会让 client 手里的 revision 过期,表现为保存时的 settings/conflict。
|
|
1229
|
+
// 60s 心跳保证 digestAt 不会永远停住。
|
|
1230
|
+
const json = JSON.stringify(digest);
|
|
1231
|
+
if (json === lastDigestJson && Date.now() - lastDigestWriteAt < 60000) return;
|
|
1232
|
+
lastDigestJson = json;
|
|
1233
|
+
lastDigestWriteAt = Date.now();
|
|
1234
|
+
bus.writeHealth({ digest, digestAt: Date.now() }).catch(() => { });
|
|
1224
1235
|
}
|
|
1225
1236
|
};
|
|
1226
1237
|
const scheduleDigest = () => {
|