@mzzsfy/dsh-maintain 0.9.0 → 0.11.0
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 +8 -4
- package/package.json +2 -3
- package/src/client.js +18 -14
- package/src/index.js +87 -80
- package/test/parity.test.mjs +12 -19
- package/test/restart-ready.test.mjs +7 -8
- package/test/route.test.mjs +125 -88
- package/test/upgrade.test.mjs +10 -20
- package/src/runtime.mjs +0 -69
- package/test/runtime.test.mjs +0 -67
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
|
|
|
9
9
|
- 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
|
|
10
10
|
- 一键安装:面板"安装"按钮全版本状态可点(升级、重装修复、回退均从面板直达,不再有"已是最新"禁用与 409 拒绝),点击弹出确认窗,窗内展示版本变更与将执行的实际命令(`{tag}` 已替换为追踪通道)及"安装完成后自动重启宿主"勾选项(默认勾选,本次安装会话级生效,不持久化),确认后才触发。版本变更按安装意图自动表述:命令模板含精确版本号(如 `@0.1.4`)即按"安装指定版本"表述并附改回 `{tag}` 的指引;否则按通道目标与运行版本分类为升级/重装/回退,信息不足(通道最新版未知或版本无法比较)时窗内明示无法判定,仍可确认安装。通道目标等于运行版本即重装(用于本体损坏或异常安装的修复);需要回退时把模板中的 `@{tag}` 改为指定版本号(如 `@0.1.4`),回退完成后改回 `{tag}` 形态,否则后续安装持续钉在该版本。安装期间串行化(含跨进程锁文件:宿主被外部重启后,残留安装子进程的锁在有效期内仍拒绝新安装),单次尝试超时 10 分钟强杀;安装后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面查看结果并按提示重启生效;继续运行路径下安装完成后自动重新检查版本。
|
|
11
11
|
- 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
|
|
12
|
-
- 安装结果校验:安装命令成功且宿主继续运行(
|
|
13
|
-
-
|
|
12
|
+
- 安装结果校验:安装命令成功且宿主继续运行(本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
|
|
13
|
+
- 升级后自动重启:升级确认弹窗勾选自动重启且命令成功(退出码 0)时,延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),动作与运行环境无关:托管启动(pm2 / systemd / 容器等)由进程管理器拉起,手动终端启动则需你重新运行宿主(等同点击重启按钮后的流程)。落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新,手动终端启动下该等待按 30 秒总时长超时转人工(见重启须知)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启;宿主不具备就地退出能力(appExit 缺失)时同样不调度。
|
|
14
14
|
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
|
|
15
15
|
- 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
|
|
16
16
|
- 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
|
|
@@ -33,7 +33,7 @@ node scripts/dev-link.mjs dsh-maintain # 仓库根执行:归一 profile 依赖
|
|
|
33
33
|
|
|
34
34
|
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。勿用 `link:` 或裸相对路径手工挂载——realpath 后 peer 从仓库目录解析会失败(早期"必须手工拷贝部署"结论的成因,对 dev-link junction 不成立,junction 内 peer 沿 profile 解析)。
|
|
35
35
|
|
|
36
|
-
## 设置(
|
|
36
|
+
## 设置(配置节,热加载)
|
|
37
37
|
|
|
38
38
|
```yaml
|
|
39
39
|
maintain:
|
|
@@ -43,7 +43,7 @@ maintain:
|
|
|
43
43
|
registryBase: https://registry.npmjs.org # 官方源不可达时可改为镜像地址
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
配置落在 profile 条目 `maintain` 的 config 节(即上例 settings.yaml 的 `maintain:` 块,经 dsh 配置迁移后为 profile patch 内该条目的 config 键):dsh 0.1.7+ 以插件静态 `Config` 导出生成节表单,四个字段均为 volatile,面板保存经 configEditor 写回、仅 volatile 变化时 loader 原地热更(不重启插件);legacy 宿主(≤0.1.6)经 settings 服务 `register/get/update` 命名空间语义等价存取。升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效配置节,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
|
|
47
47
|
|
|
48
48
|
## 重启须知
|
|
49
49
|
|
|
@@ -57,3 +57,7 @@ maintain:
|
|
|
57
57
|
```
|
|
58
58
|
cd packages/dsh-maintain && npm test
|
|
59
59
|
```
|
|
60
|
+
|
|
61
|
+
## dsh 版本兼容
|
|
62
|
+
|
|
63
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:版本与运维页控件完整、检查/升级/重启入口可用、激活 live。配置面双形态特性检测(照 dsh-llm-pi-gateway,不做版本硬编码):0.1.7-rc.1 走静态 Config 导出 + configEditor 写回 + volatile 原地热更,legacy(0.1.2–0.1.6)走 settings 命名空间 register/get/update;宿主 settings/configEditor 方法面缺失时对应能力守卫降级(面板提示),不阻塞页面。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-maintain",
|
|
3
3
|
"description": "DSH 自身的版本管理:追踪 npm dist-tag 新版本、一键升级、安全重启",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -36,8 +36,7 @@
|
|
|
36
36
|
"@mzzsfy/dsh-toast": "^0.1.2"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@deepseek-ai/
|
|
40
|
-
"@deepseek-ai/schemastery": ">=3.18.0",
|
|
39
|
+
"@deepseek-ai/schemastery": ">=3.18.4",
|
|
41
40
|
"react": "^18.2.0"
|
|
42
41
|
},
|
|
43
42
|
"files": [
|
package/src/client.js
CHANGED
|
@@ -160,8 +160,6 @@ function restartPostLost(error) {
|
|
|
160
160
|
const VERDICT_OUTDATED = 'outdated'
|
|
161
161
|
const VERDICT_UP_TO_DATE = 'up-to-date'
|
|
162
162
|
const VERDICT_UNKNOWN = 'unknown'
|
|
163
|
-
// 与 host runtime.mjs RUNTIME_KINDS.MANUAL_START 镜像(parity 对拍,单侧改名即测试失败)
|
|
164
|
-
const RUNTIME_KIND_MANUAL = 'manual-start-likely'
|
|
165
163
|
// 与 host AUTO_RESTART_DELAY_MS 镜像(parity 对拍):弹窗文案引用,勿手抄数字
|
|
166
164
|
const AUTO_RESTART_DELAY_SEC = 3
|
|
167
165
|
|
|
@@ -227,8 +225,9 @@ async function recheckUpgradeSettle(previousLast) {
|
|
|
227
225
|
if (upgradeWatch.generation !== null) return
|
|
228
226
|
if (final !== null) broadcastUpgradeStatus(final)
|
|
229
227
|
const last = final !== null && final.upgrade ? final.upgrade.last : null
|
|
230
|
-
if (last !== null &&
|
|
231
|
-
|
|
228
|
+
if (last !== null && last.autoRestartScheduled === true) showUpgradeFloat(last)
|
|
229
|
+
// 补查仍无落定结果(观察窗错过且宿主已换代):转状态未知,不再停留进行中文案
|
|
230
|
+
else showUpgradeFloat(previousLast !== null ? previousLast : 'unknown')
|
|
232
231
|
}
|
|
233
232
|
|
|
234
233
|
function subscribeUpgradeStatus(listener) {
|
|
@@ -258,10 +257,11 @@ function ensureUpgradeWatch() {
|
|
|
258
257
|
const upgrade = next ? next.upgrade : null
|
|
259
258
|
if (!upgrade || upgrade.running !== true) {
|
|
260
259
|
upgradeWatch.generation = null
|
|
261
|
-
//
|
|
260
|
+
// 落定拍(last 在)但未见调度标记不立即终判:继续运行路径的 stale 复读在
|
|
261
|
+
// 落定后异步完成,延迟宽限补查一拍再取终态;快照归零(last 空,宿主已换代)
|
|
262
|
+
// 同样补查,仍无结果即转状态未知,不留永久"进行中"
|
|
262
263
|
if (upgrade && upgrade.last) {
|
|
263
|
-
|
|
264
|
-
if (upgrade.last.autoRestartScheduled === true || upgrade.last.requiresManualRestart === true) {
|
|
264
|
+
if (upgrade.last.autoRestartScheduled === true) {
|
|
265
265
|
showUpgradeFloat(upgrade.last)
|
|
266
266
|
return
|
|
267
267
|
}
|
|
@@ -269,6 +269,11 @@ function ensureUpgradeWatch() {
|
|
|
269
269
|
if (upgradeWatch.generation !== null) return
|
|
270
270
|
void recheckUpgradeSettle(upgrade.last)
|
|
271
271
|
}, UPGRADE_AUTO_RESTART_GRACE_MS)
|
|
272
|
+
} else if (upgrade) {
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
if (upgradeWatch.generation !== null) return
|
|
275
|
+
void recheckUpgradeSettle(null)
|
|
276
|
+
}, UPGRADE_AUTO_RESTART_GRACE_MS)
|
|
272
277
|
}
|
|
273
278
|
return
|
|
274
279
|
}
|
|
@@ -306,7 +311,6 @@ function ensureUpgradeFloatStyle() {
|
|
|
306
311
|
// LOGIC-BEGIN upgradeFinalText
|
|
307
312
|
function upgradeFinalText(state) {
|
|
308
313
|
if (state.stale === true) return '升级命令执行完成,但结果与安装意图不符,详情见版本与运维页'
|
|
309
|
-
if (state.requiresManualRestart === true) return '升级完成,当前为手动直跑环境,请手动重启宿主后生效'
|
|
310
314
|
if (state.autoRestartScheduled === true) return '升级完成,宿主将自动重启,页面恢复后可直接使用'
|
|
311
315
|
return '升级完成,重启宿主后生效(版本与运维页可重启)'
|
|
312
316
|
}
|
|
@@ -661,14 +665,14 @@ function UpgradeDialog(props) {
|
|
|
661
665
|
label: '安装完成后自动重启宿主',
|
|
662
666
|
checked: autoRestart,
|
|
663
667
|
onChange: setAutoRestart,
|
|
668
|
+
disabled: status.canRestart !== true,
|
|
664
669
|
}),
|
|
665
670
|
h('div', { className: 'dm-dialog__outcome' },
|
|
666
|
-
|
|
667
|
-
? '
|
|
668
|
-
:
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
: null,
|
|
671
|
+
status.canRestart !== true
|
|
672
|
+
? '当前启动方式不支持就地退出:无论是否勾选,安装完成后都需在宿主侧重启生效。'
|
|
673
|
+
: autoRestart
|
|
674
|
+
? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出——等同点击重启按钮:托管启动时由进程管理器拉起,手动终端启动时需你重新运行宿主;本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
|
|
675
|
+
: '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
|
|
672
676
|
),
|
|
673
677
|
h('div', { className: 'dm-row' },
|
|
674
678
|
h('span', { className: 'dm-spacer' }),
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-maintain Host 半区:版本监测 + 一键升级 + 安全重启。
|
|
2
2
|
// 双端模式照 dsh-usage-panel:webServer 具名路由供浏览器半区调用;
|
|
3
|
-
//
|
|
3
|
+
// 配置持久化双形态:legacy 走 settings 命名空间 maintain,0.1.7+ 走 profile
|
|
4
|
+
// 条目 config 节(Config 导出 + volatile 热更),检查结果仅存内存。
|
|
4
5
|
|
|
5
6
|
import { readFile } from 'node:fs/promises'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
UPGRADE_FAIL_TRANSIENT_NETWORK,
|
|
27
28
|
} from './core.mjs'
|
|
28
29
|
import { runUpgrade } from './upgrade.mjs'
|
|
29
|
-
import { detectRuntimeEnv, RUNTIME_KINDS } from './runtime.mjs'
|
|
30
30
|
|
|
31
31
|
export const name = 'dsh-maintain'
|
|
32
32
|
|
|
@@ -208,16 +208,15 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
|
|
|
208
208
|
return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
211
|
+
// 升级后自动重启守卫:三条件缺一不可——成功、本次勾选 enabled、appExit 可用。
|
|
212
|
+
// 宿主退出动作与面板"确认重启"完全同一调度入口,拉起责任归启动方式:
|
|
213
|
+
// 托管环境由进程管理器拉起,手动直跑等同点了重启按钮后自行再启动
|
|
214
214
|
// 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
|
|
215
|
-
export function judgeAutoRestart({ ok,
|
|
216
|
-
if (ok !== true) return { schedule: false
|
|
217
|
-
if (
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
return { schedule: true, requiresManualRestart: false }
|
|
215
|
+
export function judgeAutoRestart({ ok, hasExit, enabled }) {
|
|
216
|
+
if (ok !== true) return { schedule: false }
|
|
217
|
+
if (enabled !== true) return { schedule: false }
|
|
218
|
+
if (hasExit !== true) return { schedule: false }
|
|
219
|
+
return { schedule: true }
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
// 浏览器半区调用的 API 路径清单:client.js 同名常量与之对拍(parity),防单侧改路径生产 404
|
|
@@ -255,19 +254,26 @@ function upgradeLockStale(lock) {
|
|
|
255
254
|
return lock === null || Date.now() - lock.startedAt > UPGRADE_LOCK_STALE_MS
|
|
256
255
|
}
|
|
257
256
|
|
|
258
|
-
const DEFAULT_CHANNEL = 'latest'
|
|
259
257
|
// 默认值导出仅供 parity 测试作 host 侧锚点;行为入口全部经 readSettings 回落
|
|
258
|
+
export const DEFAULT_CHANNEL = 'latest'
|
|
260
259
|
export const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
|
|
261
260
|
export const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g ' + TARGET_PACKAGE + '@' + TAG_PLACEHOLDER
|
|
262
261
|
export const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
|
|
263
262
|
|
|
264
263
|
// 注册即声明 GUI 设置表单,schema 默认值即生效默认值(rs-workflow-config 先例)。
|
|
265
|
-
|
|
264
|
+
// 0.1.7 宿主以静态 Config 导出生成节表单;legacy 宿主经 settings.register 注册同名命名空间。
|
|
265
|
+
// volatile 根包装(照 dsh-llm-pi-gateway volatileWrap 同构):0.1.7 下整节为 live 表单,
|
|
266
|
+
// 节写经 loader volatile 快速道原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
267
|
+
export const Config = volatileWrap(z.object({
|
|
266
268
|
channel: z.string().default(DEFAULT_CHANNEL).description('追踪通道:npm dist-tag 名(latest/next/alpha 等,以检查返回的通道列表为准)'),
|
|
267
269
|
pollIntervalSec: z.number().default(DEFAULT_POLL_INTERVAL_SEC).description('轮询间隔秒数,仅正数启用周期检查'),
|
|
268
270
|
upgradeCommandTemplate: z.string().default(DEFAULT_UPGRADE_TEMPLATE).description('升级命令模板,{tag} 执行时替换为追踪通道,可整体自改为任意命令'),
|
|
269
271
|
registryBase: z.string().default(DEFAULT_REGISTRY_BASE).description('npm registry 基地址,官方源不可达时改为镜像地址'),
|
|
270
|
-
})
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
function volatileWrap(schema) {
|
|
275
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
276
|
+
}
|
|
271
277
|
|
|
272
278
|
function sendJson(res, status, payload) {
|
|
273
279
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -328,10 +334,17 @@ function readBody(req) {
|
|
|
328
334
|
})
|
|
329
335
|
}
|
|
330
336
|
|
|
331
|
-
|
|
337
|
+
// volatile ref 动态解包(get 协议,官方 plainOptions 同构);legacy 宿主 config 为普通对象原样透传。
|
|
338
|
+
// Config 为根级 volatile 包装,解析产物是整节单 ref,解包一次即得字段明值
|
|
339
|
+
function unwrapVolatile(value) {
|
|
340
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readSettings(ctx, config) {
|
|
332
344
|
const settings = ctx.get('settings')
|
|
333
|
-
//
|
|
334
|
-
|
|
345
|
+
// 双形态读(照 dsh-llm-pi-gateway,特性检测不硬编码版本):
|
|
346
|
+
// legacy:settings 命名空间 get;0.1.7+:apply 入参 config 整节解包
|
|
347
|
+
const value = typeof settings?.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
|
|
335
348
|
return {
|
|
336
349
|
channel: value && typeof value.channel === 'string' && value.channel.trim().length > 0 ? value.channel.trim() : DEFAULT_CHANNEL,
|
|
337
350
|
pollIntervalSec:
|
|
@@ -350,7 +363,32 @@ function readSettings(ctx) {
|
|
|
350
363
|
}
|
|
351
364
|
|
|
352
365
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
353
|
-
export function apply(ctx) {
|
|
366
|
+
export function apply(ctx, config) {
|
|
367
|
+
// 配置面双形态(照 dsh-llm-pi-gateway,特性检测不硬编码版本),判定一律延迟到使用点:
|
|
368
|
+
// settings 服务挂载时序无保证,apply 时刻探测会误判形态。
|
|
369
|
+
// legacy(≤0.1.6):settings 服务 register/get/update 命名空间语义;
|
|
370
|
+
// 0.1.7+:静态 Config 导出生成节表单,读经 volatile ref 解包,写经 configEditor 落 profile 条目
|
|
371
|
+
const legacySettingsFace = () => {
|
|
372
|
+
const settings = ctx.get('settings')
|
|
373
|
+
return typeof settings?.register === 'function' && typeof settings?.update === 'function'
|
|
374
|
+
}
|
|
375
|
+
// 写路径分发。0.1.7:configEditor.edit 的 change 回调返回完整 raw config,
|
|
376
|
+
// 仅 volatile 字段变化时 loader 原地热更不重启插件,apply 无需重入。
|
|
377
|
+
// 服务面缺失发 500 后返回 false,业务异常抛出由路由统一归一 400
|
|
378
|
+
async function persistPatch(res, patch) {
|
|
379
|
+
if (legacySettingsFace()) {
|
|
380
|
+
await ctx.get('settings').update(NAMESPACE, patch)
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
const editor = ctx.get('configEditor')
|
|
384
|
+
const entry = ctx.fiber?.entry
|
|
385
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) {
|
|
386
|
+
sendJson(res, 500, { error: 'configEditor 服务不可用' })
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
354
392
|
// 启动器在挂载前提供 appExit(有界退出,5 秒兜底强制);缺失时重启能力关闭
|
|
355
393
|
const exit = ctx.get('appExit')
|
|
356
394
|
if (typeof exit !== 'function') {
|
|
@@ -386,16 +424,6 @@ export function apply(ctx) {
|
|
|
386
424
|
const runningVersionReady = resolveCurrentHostVersion()
|
|
387
425
|
.then((version) => { runningVersion = version })
|
|
388
426
|
.catch(() => {})
|
|
389
|
-
|
|
390
|
-
// 运行环境:apply 时一次检测;探测异步,以 ready 链收口,异常回退 unknown(维持自动重启)
|
|
391
|
-
let runtimeEnv = { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
|
|
392
|
-
const runtimeEnvReady = detectRuntimeEnv({
|
|
393
|
-
env: process.env,
|
|
394
|
-
platform: process.platform,
|
|
395
|
-
isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
|
|
396
|
-
})
|
|
397
|
-
.then((env) => { runtimeEnv = env })
|
|
398
|
-
.catch(() => {})
|
|
399
427
|
let upgrade = { running: false, last: null }
|
|
400
428
|
let checkInFlight = null
|
|
401
429
|
let nextDueAt = null
|
|
@@ -407,9 +435,9 @@ export function apply(ctx) {
|
|
|
407
435
|
function runCheck() {
|
|
408
436
|
if (checkInFlight) return checkInFlight
|
|
409
437
|
checkInFlight = (async () => {
|
|
410
|
-
const
|
|
438
|
+
const cfg = readSettings(ctx, config)
|
|
411
439
|
try {
|
|
412
|
-
snapshot.tags = await fetchDistTags({ registryBase:
|
|
440
|
+
snapshot.tags = await fetchDistTags({ registryBase: cfg.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
|
|
413
441
|
snapshot.error = null
|
|
414
442
|
} catch (error) {
|
|
415
443
|
// registry 不可达:保留上次 tags,仅记录错误
|
|
@@ -432,7 +460,7 @@ export function apply(ctx) {
|
|
|
432
460
|
}
|
|
433
461
|
|
|
434
462
|
function scheduleNext() {
|
|
435
|
-
const intervalSec = readSettings(ctx).pollIntervalSec
|
|
463
|
+
const intervalSec = readSettings(ctx, config).pollIntervalSec
|
|
436
464
|
nextDueAt = intervalSec > 0 && intervalSec <= POLL_INTERVAL_MAX_SEC ? Date.now() + intervalSec * 1000 : null
|
|
437
465
|
}
|
|
438
466
|
|
|
@@ -456,13 +484,13 @@ export function apply(ctx) {
|
|
|
456
484
|
|
|
457
485
|
function judgeNow() {
|
|
458
486
|
// verdict 以运行版本判定:用户关心"跑的是不是最新";已装版本供升级复读校验
|
|
459
|
-
return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx).channel })
|
|
487
|
+
return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx, config).channel })
|
|
460
488
|
}
|
|
461
489
|
|
|
462
490
|
async function currentStatus() {
|
|
463
|
-
//
|
|
464
|
-
await
|
|
465
|
-
const
|
|
491
|
+
// 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
|
|
492
|
+
await runningVersionReady
|
|
493
|
+
const cfg = readSettings(ctx, config)
|
|
466
494
|
const judged = judgeNow()
|
|
467
495
|
// running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
|
|
468
496
|
const lock = upgrade.running === true ? { startedAt: Date.now() } : readUpgradeLock()
|
|
@@ -475,10 +503,10 @@ export function apply(ctx) {
|
|
|
475
503
|
runningVersion,
|
|
476
504
|
installedVersion: snapshot.installedVersion,
|
|
477
505
|
restartPending: isVersionPendingRestart({ runningVersion, installedVersion: snapshot.installedVersion }),
|
|
478
|
-
channel:
|
|
479
|
-
upgradeTemplate:
|
|
480
|
-
pollIntervalSec:
|
|
481
|
-
registryBase:
|
|
506
|
+
channel: cfg.channel,
|
|
507
|
+
upgradeTemplate: cfg.upgradeCommandTemplate,
|
|
508
|
+
pollIntervalSec: cfg.pollIntervalSec,
|
|
509
|
+
registryBase: cfg.registryBase,
|
|
482
510
|
tags: snapshot.tags,
|
|
483
511
|
channelLatest: judged.channelLatest,
|
|
484
512
|
verdict: judged.verdict,
|
|
@@ -486,7 +514,6 @@ export function apply(ctx) {
|
|
|
486
514
|
checkedAt: snapshot.checkedAt,
|
|
487
515
|
checkError: snapshot.error,
|
|
488
516
|
upgrade,
|
|
489
|
-
runtimeEnv,
|
|
490
517
|
autoRestartScheduled,
|
|
491
518
|
activeWork: collectActiveWork(ctx),
|
|
492
519
|
canRestart: typeof exit === 'function',
|
|
@@ -494,15 +521,15 @@ export function apply(ctx) {
|
|
|
494
521
|
}
|
|
495
522
|
|
|
496
523
|
function triggerUpgrade(autoRestart) {
|
|
497
|
-
const
|
|
524
|
+
const cfg = readSettings(ctx, config)
|
|
498
525
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
499
|
-
const command = buildUpgradeCommand({ template:
|
|
526
|
+
const command = buildUpgradeCommand({ template: cfg.upgradeCommandTemplate, tag: cfg.channel })
|
|
500
527
|
// 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
|
|
501
528
|
const pinnedVersion = extractPinnedVersion(command)
|
|
502
529
|
const last = {
|
|
503
530
|
command,
|
|
504
531
|
// 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
|
|
505
|
-
pinnedVersion: pinnedVersion ===
|
|
532
|
+
pinnedVersion: pinnedVersion === cfg.channel ? null : pinnedVersion,
|
|
506
533
|
startedAt: Date.now(),
|
|
507
534
|
ok: false,
|
|
508
535
|
finishedAt: null,
|
|
@@ -523,7 +550,7 @@ export function apply(ctx) {
|
|
|
523
550
|
upgrade = { running: true, last }
|
|
524
551
|
writeUpgradeLock(last.startedAt)
|
|
525
552
|
audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
|
|
526
|
-
void performUpgrade(command, last,
|
|
553
|
+
void performUpgrade(command, last, cfg.channel, autoRestart === true)
|
|
527
554
|
}
|
|
528
555
|
|
|
529
556
|
// 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
|
|
@@ -592,11 +619,9 @@ export function apply(ctx) {
|
|
|
592
619
|
+ (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
|
|
593
620
|
audit('upgrade', last.ok === true ? 'ok' : 'failed',
|
|
594
621
|
'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
|
|
595
|
-
// 关机前置判定只取内存输入(
|
|
622
|
+
// 关机前置判定只取内存输入(本次会话勾选 + 启动器退出能力):升级命令刚
|
|
596
623
|
// 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
|
|
597
|
-
|
|
598
|
-
const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
599
|
-
if (decision.requiresManualRestart === true) last.requiresManualRestart = true
|
|
624
|
+
const decision = judgeAutoRestart({ ok: last.ok === true, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
600
625
|
if (decision.schedule === true) {
|
|
601
626
|
// 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
|
|
602
627
|
// last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
|
|
@@ -604,7 +629,7 @@ export function apply(ctx) {
|
|
|
604
629
|
scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
|
|
605
630
|
return
|
|
606
631
|
}
|
|
607
|
-
// 继续运行路径(
|
|
632
|
+
// 继续运行路径(失败/本次未勾选自动重启/appExit 缺失):复读磁盘版本标 stale,版本未前进提示保留
|
|
608
633
|
if (settle !== null && settle.ok) {
|
|
609
634
|
// 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
|
|
610
635
|
// 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
|
|
@@ -665,12 +690,7 @@ export function apply(ctx) {
|
|
|
665
690
|
sendJson(res, 400, { error: '通道 ' + channel + ' 不在当前 dist-tags 中' })
|
|
666
691
|
return
|
|
667
692
|
}
|
|
668
|
-
|
|
669
|
-
if (!settings) {
|
|
670
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
671
|
-
return
|
|
672
|
-
}
|
|
673
|
-
await settings.update(NAMESPACE, { channel })
|
|
693
|
+
if (!(await persistPatch(res, { channel }))) return
|
|
674
694
|
// 切通道无需重查:dist-tags 与 channel 无关,白名单已用现有 snapshot 校验
|
|
675
695
|
scheduleNext()
|
|
676
696
|
sendJson(res, 200, await currentStatus())
|
|
@@ -685,12 +705,7 @@ export function apply(ctx) {
|
|
|
685
705
|
sendJson(res, 400, { error: '升级命令不能为空' })
|
|
686
706
|
return
|
|
687
707
|
}
|
|
688
|
-
|
|
689
|
-
if (!settings) {
|
|
690
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
691
|
-
return
|
|
692
|
-
}
|
|
693
|
-
await settings.update(NAMESPACE, { upgradeCommandTemplate: template })
|
|
708
|
+
if (!(await persistPatch(res, { upgradeCommandTemplate: template }))) return
|
|
694
709
|
sendJson(res, 200, await currentStatus())
|
|
695
710
|
}),
|
|
696
711
|
},
|
|
@@ -709,12 +724,7 @@ export function apply(ctx) {
|
|
|
709
724
|
sendJson(res, 400, { error: '轮询间隔不能超过 ' + POLL_INTERVAL_MAX_SEC + ' 秒' })
|
|
710
725
|
return
|
|
711
726
|
}
|
|
712
|
-
|
|
713
|
-
if (!settings) {
|
|
714
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
715
|
-
return
|
|
716
|
-
}
|
|
717
|
-
await settings.update(NAMESPACE, { pollIntervalSec: seconds })
|
|
727
|
+
if (!(await persistPatch(res, { pollIntervalSec: seconds }))) return
|
|
718
728
|
scheduleNext()
|
|
719
729
|
sendJson(res, 200, await currentStatus())
|
|
720
730
|
}),
|
|
@@ -728,12 +738,7 @@ export function apply(ctx) {
|
|
|
728
738
|
sendJson(res, 400, { error: 'registry 基地址须为 http(s) 地址且不带查询串或锚点' })
|
|
729
739
|
return
|
|
730
740
|
}
|
|
731
|
-
|
|
732
|
-
if (!settings) {
|
|
733
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
734
|
-
return
|
|
735
|
-
}
|
|
736
|
-
await settings.update(NAMESPACE, { registryBase: base })
|
|
741
|
+
if (!(await persistPatch(res, { registryBase: base }))) return
|
|
737
742
|
if (restartScheduled) {
|
|
738
743
|
// 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
|
|
739
744
|
sendJson(res, 200, await currentStatus())
|
|
@@ -858,14 +863,16 @@ export function apply(ctx) {
|
|
|
858
863
|
}
|
|
859
864
|
|
|
860
865
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
861
|
-
// 方法面防御对齐 timer
|
|
862
|
-
//
|
|
863
|
-
if (
|
|
864
|
-
|
|
865
|
-
|
|
866
|
+
// 方法面防御对齐 timer 软依赖:settings 服务缺 register 面(0.1.7 已移除)即走
|
|
867
|
+
// 新形态分支,不在此路径抛错拖垮启动检查
|
|
868
|
+
if (legacySettingsFace()) {
|
|
869
|
+
settingsCtx.settings.register(NAMESPACE, Config)
|
|
870
|
+
} else if (typeof settingsCtx.settings?.configure === 'function') {
|
|
871
|
+
// 0.1.7 形态:原生自动设置页与本包自定义面板重复,特性检测关闭
|
|
872
|
+
// (官方 dsh-agent-default-model 同构)
|
|
873
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
866
874
|
}
|
|
867
|
-
|
|
868
|
-
// 启动检查放在 settings 注册之后:命名空间未注册时 readSettings 只能拿默认值,
|
|
875
|
+
// 启动检查在 settings 注册之后:legacy 命名空间未注册时 readSettings 只能拿默认值,
|
|
869
876
|
// 配置了镜像地址的部署会确定性检查失败
|
|
870
877
|
runCheck().then(scheduleNext, scheduleNext)
|
|
871
878
|
})
|
package/test/parity.test.mjs
CHANGED
|
@@ -138,11 +138,11 @@ test('渲染冒烟: UpgradeDialog 工厂化执行不抛错且含版本变更行'
|
|
|
138
138
|
const useState = (init) => [init, () => {}]
|
|
139
139
|
const Switch = (propsArg) => h('label', propsArg)
|
|
140
140
|
const dialog = new Function('h', 'useState', 'Switch', 'DEFAULT_UPGRADE_TEMPLATE', 'AUTO_RESTART_DELAY_SEC',
|
|
141
|
-
'
|
|
141
|
+
'buildInstallChangeLine', 'buildInstallVersionHint',
|
|
142
142
|
'return (' + match[0] + ')')(
|
|
143
|
-
h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3,
|
|
143
|
+
h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3,
|
|
144
144
|
(status, command) => '变更:' + command, (status) => '提示:' + status.runningVersion)
|
|
145
|
-
const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5'
|
|
145
|
+
const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5' }
|
|
146
146
|
const tree = dialog({ status, onCancel: () => {}, onConfirm: () => {} })
|
|
147
147
|
assert.equal(tree.type, 'div', '弹窗根为遮罩 div')
|
|
148
148
|
assert.ok(calls.some((type) => type === 'pre'), '命令 pre 节点应在树中')
|
|
@@ -200,13 +200,6 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
|
|
|
200
200
|
assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
-
// host 侧锚点直接 import runtime.mjs 实现,防测试内手抄字面量漂移假绿
|
|
204
|
-
import { RUNTIME_KINDS } from '../src/runtime.mjs'
|
|
205
|
-
|
|
206
|
-
test('parity: 手动直跑运行环境常量 client 与 host 一致', () => {
|
|
207
|
-
assert.equal(extractConst('RUNTIME_KIND_MANUAL'), RUNTIME_KINDS.MANUAL_START)
|
|
208
|
-
})
|
|
209
|
-
|
|
210
203
|
test('parity: 弹窗文案自动重启延迟秒与 host AUTO_RESTART_DELAY_MS 换算一致', () => {
|
|
211
204
|
assert.equal(extractNumberConst('AUTO_RESTART_DELAY_SEC'), AUTO_RESTART_DELAY_MS / 1000)
|
|
212
205
|
})
|
|
@@ -255,12 +248,13 @@ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
|
|
|
255
248
|
}
|
|
256
249
|
})
|
|
257
250
|
|
|
258
|
-
//
|
|
251
|
+
// 窗口关系对拍:重启等待总时长必须大于任一退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
|
|
259
252
|
import { RESTART_DELAY_MS } from '../src/index.js'
|
|
260
253
|
|
|
261
|
-
test('parity: 重启等待总时长大于宿主退出延迟', () => {
|
|
254
|
+
test('parity: 重启等待总时长大于宿主退出延迟(按钮与自动重启取大)', () => {
|
|
262
255
|
const restartTimeoutMs = extractNumberConst('RESTART_TIMEOUT_MS')
|
|
263
|
-
assert.ok(restartTimeoutMs > RESTART_DELAY_MS,
|
|
256
|
+
assert.ok(restartTimeoutMs > Math.max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS),
|
|
257
|
+
'RESTART_TIMEOUT_MS 必须大于 max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS)')
|
|
264
258
|
})
|
|
265
259
|
|
|
266
260
|
test('parity: 升级观察上限覆盖宿主重试链上限(防抢跑转状态未知)', () => {
|
|
@@ -286,13 +280,12 @@ test('parity: GitHub releases 兜底链接与 host 拉取仓库同源', () => {
|
|
|
286
280
|
assert.equal(extractConst('RELEASES_PAGE_URL'), 'https://github.com/' + RELEASE_REPO + '/releases')
|
|
287
281
|
})
|
|
288
282
|
|
|
289
|
-
test('parity:
|
|
290
|
-
//
|
|
283
|
+
test('parity: 落定补查宽限不小于观察轮询拍间隔', () => {
|
|
284
|
+
// 关机路径调度标记与 running=false 同拍置位,宽限不再竞态调度延迟;现职责是
|
|
285
|
+
// 给继续运行路径的 stale 异步复读留余量,下限锁定为不小于轮询拍间隔(补查不早于一拍)
|
|
291
286
|
const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
|
|
292
|
-
assert.ok(
|
|
293
|
-
|
|
294
|
-
'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于 AUTO_RESTART_DELAY_MS(' + AUTO_RESTART_DELAY_MS + ') 加观察裕量',
|
|
295
|
-
)
|
|
287
|
+
assert.ok(graceMs >= 2 * 1000,
|
|
288
|
+
'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于观察轮询拍间隔(2s)')
|
|
296
289
|
})
|
|
297
290
|
|
|
298
291
|
test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
|
|
@@ -197,24 +197,23 @@ test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=p
|
|
|
197
197
|
assert.match(callSite[1], /readyStreak:\s*prev\.readyStreak/, 'restartTick 调用点缺少 readyStreak: prev.readyStreak 实参')
|
|
198
198
|
})
|
|
199
199
|
|
|
200
|
-
// 升级浮条终态文案:ok 落定后 stale /
|
|
200
|
+
// 升级浮条终态文案:ok 落定后 stale / 自动重启两条终态分流,失败与未知不在此列
|
|
201
201
|
const clientUpgradeFinalText = extractLogic('upgradeFinalText')
|
|
202
202
|
|
|
203
203
|
// 重启确认态文案:armed 且有活跃计数时改「仍要重启」
|
|
204
204
|
const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
|
|
205
205
|
|
|
206
|
-
test('upgradeFinalText: stale
|
|
207
|
-
const text = clientUpgradeFinalText({ ok: true, stale: true,
|
|
206
|
+
test('upgradeFinalText: stale 优先于重启指引,不得引导重启', () => {
|
|
207
|
+
const text = clientUpgradeFinalText({ ok: true, stale: true, autoRestartScheduled: true })
|
|
208
208
|
// stale 口径泛化:未前进/未达目标/未落到目标/未装到指定版本统称与安装意图不符
|
|
209
209
|
assert.match(text, /意图不符/)
|
|
210
210
|
assert.doesNotMatch(text, /重启/)
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
-
test('upgradeFinalText:
|
|
214
|
-
assert.match(clientUpgradeFinalText({ ok: true, stale: false,
|
|
215
|
-
assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: true }), /自动重启/)
|
|
213
|
+
test('upgradeFinalText: 自动重启终态指向页面自恢复,默认终态指向手动重启', () => {
|
|
214
|
+
assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: true }), /自动重启/)
|
|
216
215
|
// 默认成功终态
|
|
217
|
-
assert.match(clientUpgradeFinalText({ ok: true, stale: false,
|
|
216
|
+
assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: false }), /重启宿主/)
|
|
218
217
|
})
|
|
219
218
|
|
|
220
219
|
test('restartConfirmLabel: armed 且活跃计数>0 才显示仍要重启文案', () => {
|
|
@@ -240,7 +239,7 @@ test('client 自动重启接管:单一守卫函数,订阅回调与初始加载
|
|
|
240
239
|
|
|
241
240
|
test('client 落定补查:未见自动重启标记时延迟宽限补查一拍再终判', () => {
|
|
242
241
|
const source = clientSource()
|
|
243
|
-
//
|
|
242
|
+
// 落定拍可见时继续运行路径的 stale 复读可能尚未完成:未见调度标记不得立即终判
|
|
244
243
|
assert.match(source, /setTimeout\(\(\)\s*=>\s*\{[\s\S]*?recheckUpgradeSettle\(/, '落定拍未见标记必须延迟补查')
|
|
245
244
|
assert.match(source, /async function recheckUpgradeSettle\([\s\S]*?broadcastUpgradeStatus\(final\)/, '补查必须广播以驱动接管守卫')
|
|
246
245
|
assert.match(source, /upgradeWatch\.generation !== null\) return[\s\S]*?recheckUpgradeSettle|recheckUpgradeSettle\([\s\S]*?upgradeWatch\.generation !== null\) return/, '补查前后必须验让位新观察')
|