@mzzsfy/dsh-maintain 0.5.3 → 0.6.1
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 +13 -10
- package/package.json +1 -1
- package/src/client.js +127 -23
- package/src/core.mjs +58 -0
- package/src/index.js +369 -48
- package/src/runtime.mjs +69 -0
- package/src/upgrade.mjs +3 -2
- package/test/client-scope.test.mjs +54 -0
- package/test/core.test.mjs +135 -0
- package/test/parity.test.mjs +30 -3
- package/test/restart-ready.test.mjs +65 -9
- package/test/route.test.mjs +314 -23
- package/test/runtime.test.mjs +67 -0
- package/test/upgrade.test.mjs +172 -0
package/README.md
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm 新版本、一键升级、安全重启。
|
|
4
4
|
|
|
5
|
-
设计文档见 [docs/design/dsh-maintain.md](../../docs/design/dsh-maintain.md)。
|
|
6
|
-
|
|
7
5
|
## 功能
|
|
8
6
|
|
|
9
|
-
- 版本监测:host 启动即检查一次,按轮询间隔重复检查(内存快照,不持久化)
|
|
7
|
+
- 版本监测:host 启动即检查一次,按轮询间隔重复检查(内存快照,不持久化);当前版本与追踪通道最新版对比,落后即提示。运行版本(宿主启动时缓存)与已装版本(磁盘实时)分开统计与展示,落后判定以运行版本为准,已装版本领先运行版本时提示"重启后生效"。定时轮询为软依赖:宿主 timer 服务不可用时仅停用自动轮询,面板显示降级提示,手动检查与升级能力不受影响。
|
|
10
8
|
- 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
|
|
11
|
-
- 一键升级:两段式确认后按自定义命令模板执行,`{tag}` 执行时替换为追踪通道;命令可在设置面板直接查看与编辑,升级期间串行化(含跨进程锁文件:宿主被外部重启后,残留升级子进程的锁在有效期内仍拒绝新升级)
|
|
9
|
+
- 一键升级:两段式确认后按自定义命令模板执行,`{tag}` 执行时替换为追踪通道;命令可在设置面板直接查看与编辑,升级期间串行化(含跨进程锁文件:宿主被外部重启后,残留升级子进程的锁在有效期内仍拒绝新升级),单次尝试超时 10 分钟强杀;升级后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面即可重启生效;升级完成后自动重新检查版本。
|
|
10
|
+
- 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
|
|
11
|
+
- 升级结果校验:升级成功落定后复读磁盘版本对比——版本未前进(镜像滞后或静默失败)或未达通道目标版本时,面板与浮条标注 stale 提示,不误导重启;运行版本已是通道最新时升级入口直接拒绝(409 防降级,面板按钮同步禁用),重装场景走命令行。超时强杀意味着安装可能只完成一半,重启宿主前先确认命令需否重跑。
|
|
12
|
+
- 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级成功且复读校验通过时,托管环境下延迟 3 秒自动重启宿主(复用手动重启互斥,调度窗口内升级被拒),浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启;检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或 stale 不调度。
|
|
13
|
+
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
|
|
14
|
+
- 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
|
|
12
15
|
- 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
|
|
13
16
|
- 操作反馈:检查更新、通道切换、面板保存与升级触发的失败经全站浮出通知(@mzzsfy/dsh-toast)提示;页面加载失败与重启超时等状态提示仍在面板内展示。
|
|
14
17
|
- 安全重启:两段式确认后 host 优雅退出(`appExit`,5 秒兜底强制);重启指令在响应发出后延迟执行,退出调度与响应冲刷解耦,客户端断连不影响宿主退出,浏览器先收到回执进入重启等待态;确认后面板立即提示重启进行中,轮询探测宿主恢复,且主文档连续多次探测可加载(宿主页面服务 fallback 已就绪并稳定)后再稍候片刻才刷新页面,避免启动窗口内刷新拿到 404,总时长 30 秒,超时提示转人工检查。动作路由带跨源守卫(Origin 与 Host 不符即 403,阻断跨站简单请求),与外层鉴权插件(如 dsh-web-startup-auth)互补;未部署鉴权插件时同源请求仍匿名可达。
|
|
@@ -19,15 +22,15 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
|
|
|
19
22
|
dsh plugin --profile web add @mzzsfy/dsh-maintain
|
|
20
23
|
```
|
|
21
24
|
|
|
22
|
-
标准插件安装:包进入 profile node_modules(pnpm
|
|
25
|
+
标准插件安装:包进入 profile node_modules(pnpm hoisted 布局),bundle patch 随下次 dsh 重启自动生效,无需手动编辑 cordis.patch.yml;宿主 peer(`@deepseek-ai/*`)不落盘,经目录逐级兜底解析到 dsh 本体全局安装目录。
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
开发安装(仓库工作副本直挂,不经 npm 发布):
|
|
25
28
|
|
|
26
29
|
```sh
|
|
27
|
-
dsh
|
|
30
|
+
node scripts/dev-link.mjs dsh-maintain # 仓库根执行:归一 profile 依赖行 + 挂 junction
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。勿用 `link:` 或裸相对路径手工挂载——realpath 后 peer 从仓库目录解析会失败(早期"必须手工拷贝部署"结论的成因,对 dev-link junction 不成立,junction 内 peer 沿 profile 解析)。
|
|
31
34
|
|
|
32
35
|
## 设置(settings.yaml,热加载)
|
|
33
36
|
|
|
@@ -43,9 +46,9 @@ maintain:
|
|
|
43
46
|
|
|
44
47
|
## 重启须知
|
|
45
48
|
|
|
46
|
-
- 重启依赖托管常驻(pm2 / systemd /
|
|
49
|
+
- 重启依赖托管常驻(pm2 / systemd / supervisord / Kubernetes / 容器等)自动拉起;手动终端启动的进程不会自动恢复。重启判定为三信号:失联恢复、宿主进程启动时刻(bootAt,容器内 pid 恒 1 场景的可靠信号)或 pid 变化。
|
|
47
50
|
- 确认重启后本页以严格顺序轮询探测宿主恢复(任意时刻至多一拍在途),主文档连续多次探测可加载(页面服务就绪并稳定)后稍候片刻才整页刷新;面板后台探测请求失败或拿到 404 均属宿主退出与启动窗口的中间态,面板保持等待提示。重启请求本身收到明确 HTTP 回绝(如升级互斥 409、appExit 缺失 500)时立即提示失败,不进入等待轮询。若你手动刷新后浏览器显示 404,说明宿主仍在启动窗口内,稍候数秒再次刷新即可。轮询总时长 30 秒,超时通常是宿主未被进程管理器拉起(手动终端启动)、退出失败或启动过慢,请检查进程管理器后手动刷新排查。
|
|
48
|
-
-
|
|
51
|
+
- 存在活跃工作(agent 会话/后台 job/终端)时重启被 409 拒绝,确认按钮显示活跃计数,再次点击即以 force 越过;force 重启不等待任务收尾,最多 5 秒后强制退出,任务中断;会话已持久化,重开后可 resume。升级成功触发的自动重启同样会中断在跑任务,依赖托管的活跃工作请自行评估升级时机。
|
|
49
52
|
- 升级进行中重启请求被拒绝(409):升级子进程会在宿主退出后继续存活完成安装,此时重启会导致二次升级并发写全局目录,等待升级完成后重试。
|
|
50
53
|
|
|
51
54
|
## 开发
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -130,12 +130,14 @@ const VERDICT_OUTDATED = 'outdated'
|
|
|
130
130
|
const VERDICT_UP_TO_DATE = 'up-to-date'
|
|
131
131
|
const VERDICT_UNKNOWN = 'unknown'
|
|
132
132
|
|
|
133
|
-
// 非 2xx 应答抛带 status 的错误对象:调用方据此区分"活宿主明确回绝"(有 status)
|
|
134
|
-
// 与"网络失联"(fetch reject TypeError/abort DOMException,无 status)
|
|
133
|
+
// 非 2xx 应答抛带 status 与完整 payload 的错误对象:调用方据此区分"活宿主明确回绝"(有 status)
|
|
134
|
+
// 与"网络失联"(fetch reject TypeError/abort DOMException,无 status);payload 供活跃工作
|
|
135
|
+
// 409 回绝回写 items 计数
|
|
135
136
|
// LOGIC-BEGIN apiError
|
|
136
137
|
function apiError(response, payload) {
|
|
137
138
|
const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
138
139
|
error.status = response.status
|
|
140
|
+
error.payload = payload === undefined || payload === null ? {} : payload
|
|
139
141
|
return error
|
|
140
142
|
}
|
|
141
143
|
// LOGIC-END apiError
|
|
@@ -156,10 +158,13 @@ function post(url, body) {
|
|
|
156
158
|
|
|
157
159
|
// 升级观察器:模块级单例,升级进行中每拍拉取状态并广播,组件挂载与否不影响;
|
|
158
160
|
// 发现任意一拍不在进行中即升级落定,展示结果浮条后停止轮询;
|
|
159
|
-
//
|
|
161
|
+
// 总时长上限覆盖宿主重试链上限(尝试次数×单次超时+退避+强杀宽限,见 parity 对拍):
|
|
162
|
+
// 超限说明宿主或进程管理器异常,浮条转状态未知。
|
|
160
163
|
// 代际令牌防重叠拍:顺序自调度(settle 后排下一拍),单请求带超时,stop 后迟到拍经验代际丢弃
|
|
161
|
-
const UPGRADE_WATCH_MAX_MS =
|
|
164
|
+
const UPGRADE_WATCH_MAX_MS = 32 * 60 * 1000
|
|
162
165
|
const UPGRADE_POLL_TIMEOUT_MS = 5 * 1000
|
|
166
|
+
// 落定补查宽限:宿主自动重启调度延迟与观察裕量之和(parity 对拍提取形态限单项乘积)
|
|
167
|
+
const UPGRADE_AUTO_RESTART_GRACE_MS = 5 * 1000
|
|
163
168
|
const upgradeWatch = { generation: null, startedAt: 0, listeners: new Set() }
|
|
164
169
|
|
|
165
170
|
function broadcastUpgradeStatus(status) {
|
|
@@ -170,6 +175,22 @@ function stopUpgradeWatch() {
|
|
|
170
175
|
upgradeWatch.generation = null
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
// 落定补查:延迟窗口后单拍重拉状态并广播(驱动自动重启接管守卫),按标记终判;
|
|
179
|
+
// 补查前后验让位——新一轮升级观察已启动即放弃,其自身落定拍会终判
|
|
180
|
+
async function recheckUpgradeSettle(previousLast) {
|
|
181
|
+
let final = null
|
|
182
|
+
try {
|
|
183
|
+
final = await api(STATUS_URL, { signal: AbortSignal.timeout(UPGRADE_POLL_TIMEOUT_MS) })
|
|
184
|
+
} catch {
|
|
185
|
+
final = null
|
|
186
|
+
}
|
|
187
|
+
if (upgradeWatch.generation !== null) return
|
|
188
|
+
if (final !== null) broadcastUpgradeStatus(final)
|
|
189
|
+
const last = final !== null && final.upgrade ? final.upgrade.last : null
|
|
190
|
+
if (last !== null && (last.autoRestartScheduled === true || last.requiresManualRestart === true)) showUpgradeFloat(last)
|
|
191
|
+
else showUpgradeFloat(previousLast)
|
|
192
|
+
}
|
|
193
|
+
|
|
173
194
|
function subscribeUpgradeStatus(listener) {
|
|
174
195
|
upgradeWatch.listeners.add(listener)
|
|
175
196
|
return () => upgradeWatch.listeners.delete(listener)
|
|
@@ -198,7 +219,17 @@ function ensureUpgradeWatch() {
|
|
|
198
219
|
if (!upgrade || upgrade.running !== true) {
|
|
199
220
|
upgradeWatch.generation = null
|
|
200
221
|
// 快照归零(last 为空)不做失败渲染,保持进行中文案,由重启流程接管
|
|
201
|
-
if (upgrade && upgrade.last)
|
|
222
|
+
if (upgrade && upgrade.last) {
|
|
223
|
+
// 落定拍可能早于宿主置调度标记:未见标记不立即终判,延迟宽限后补查一拍
|
|
224
|
+
if (upgrade.last.autoRestartScheduled === true || upgrade.last.requiresManualRestart === true) {
|
|
225
|
+
showUpgradeFloat(upgrade.last)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
setTimeout(() => {
|
|
229
|
+
if (upgradeWatch.generation !== null) return
|
|
230
|
+
void recheckUpgradeSettle(upgrade.last)
|
|
231
|
+
}, UPGRADE_AUTO_RESTART_GRACE_MS)
|
|
232
|
+
}
|
|
202
233
|
return
|
|
203
234
|
}
|
|
204
235
|
} catch (pollError) {
|
|
@@ -228,6 +259,17 @@ function ensureUpgradeFloatStyle() {
|
|
|
228
259
|
document.head.appendChild(style)
|
|
229
260
|
}
|
|
230
261
|
|
|
262
|
+
// 升级成功落定的浮条终态文案:stale(执行成功但目标未达成)最优先且不得提及重启,
|
|
263
|
+
// 其后按宿主是否自动重启分流指引
|
|
264
|
+
// LOGIC-BEGIN upgradeFinalText
|
|
265
|
+
function upgradeFinalText(state) {
|
|
266
|
+
if (state.stale === true) return '升级命令执行完成,但磁盘版本未前进或未达目标,详情见版本与运维页'
|
|
267
|
+
if (state.requiresManualRestart === true) return '升级完成,当前为手动直跑环境,请手动重启宿主后生效'
|
|
268
|
+
if (state.autoRestartScheduled === true) return '升级完成,宿主将自动重启,页面恢复后可直接使用'
|
|
269
|
+
return '升级完成,重启宿主后生效(版本与运维页可重启)'
|
|
270
|
+
}
|
|
271
|
+
// LOGIC-END upgradeFinalText
|
|
272
|
+
|
|
231
273
|
// state:null=进行中;对象=升级结果(last,ok 区分成败);'unknown'=观察超限状态未知
|
|
232
274
|
function showUpgradeFloat(state) {
|
|
233
275
|
ensureUpgradeFloatStyle()
|
|
@@ -246,7 +288,7 @@ function showUpgradeFloat(state) {
|
|
|
246
288
|
} else if (state === 'unknown') {
|
|
247
289
|
text.textContent = '升级状态长时间未更新,请刷新页面查看'
|
|
248
290
|
} else if (state.ok) {
|
|
249
|
-
text.textContent =
|
|
291
|
+
text.textContent = upgradeFinalText(state)
|
|
250
292
|
} else if (state.stillRunning === true) {
|
|
251
293
|
// 强杀后进程树疑似仍存活:升级入口已由锁锁定直至过期,不做"可重跑"的误导指引
|
|
252
294
|
text.textContent = '升级超时已终止;旧进程可能仍在运行,升级入口已锁定直至其退出或锁过期'
|
|
@@ -329,8 +371,8 @@ function VersionCard(props) {
|
|
|
329
371
|
const checked = fmtTime(status.checkedAt)
|
|
330
372
|
return h('div', { className: 'dm-card' },
|
|
331
373
|
h('div', { className: 'dm-row' },
|
|
332
|
-
h('span', { className: 'dm-row__label' }, '
|
|
333
|
-
h('span', null, status.
|
|
374
|
+
h('span', { className: 'dm-row__label' }, '版本'),
|
|
375
|
+
h('span', null, '运行 ' + (status.runningVersion || '未知') + ' / 已装 ' + (status.installedVersion || '未知')),
|
|
334
376
|
h(VerdictBadge, { status }),
|
|
335
377
|
h('span', { className: 'dm-spacer' }),
|
|
336
378
|
h('button', {
|
|
@@ -340,12 +382,19 @@ function VersionCard(props) {
|
|
|
340
382
|
}, props.busy.refresh ? '检查中…' : '刷新'),
|
|
341
383
|
h('button', {
|
|
342
384
|
className: 'dm-btn',
|
|
343
|
-
// upgradeLockHeld 为 host 权威信号(锁文件时效化)
|
|
385
|
+
// upgradeLockHeld 为 host 权威信号(锁文件时效化):升级进行中或残留锁未过期时禁用;
|
|
386
|
+
// 运行版本已是通道最新时同样禁用(host 侧同步 409 拒绝,防重装降级)
|
|
344
387
|
disabled: props.busy.upgrade || props.restarting || (status.upgrade && status.upgrade.running)
|
|
345
|
-
|| status.upgradeLockHeld === true
|
|
388
|
+
|| status.upgradeLockHeld === true
|
|
389
|
+
|| status.verdict === VERDICT_UP_TO_DATE,
|
|
390
|
+
title: status.verdict === VERDICT_UP_TO_DATE ? '当前已是通道最新版,无需升级;重装请走命令行' : undefined,
|
|
346
391
|
onClick: props.onUpgrade,
|
|
347
392
|
}, props.upgradeArmed ? '确认升级' : '升级'),
|
|
348
393
|
),
|
|
394
|
+
status.restartPending === true
|
|
395
|
+
? h('div', { className: 'dm-notice dm-notice--warn' },
|
|
396
|
+
'已装新版本 ' + (status.installedVersion || '') + ',重启宿主后生效。')
|
|
397
|
+
: null,
|
|
349
398
|
h('div', { className: 'dm-row' },
|
|
350
399
|
h('span', { className: 'dm-row__label' }, '追踪通道'),
|
|
351
400
|
tagNames.length > 0
|
|
@@ -419,23 +468,41 @@ function UpgradeCard(props) {
|
|
|
419
468
|
: null,
|
|
420
469
|
!upgrade.running && last
|
|
421
470
|
? h('div', { className: 'dm-row' },
|
|
422
|
-
|
|
423
|
-
|
|
471
|
+
// stale(执行成功但版本未前进/未达目标)时成功行降格为中性表述,不引导重启,
|
|
472
|
+
// 与下方 stale 警告行自洽
|
|
473
|
+
last.ok ? h('span', { className: last.stale === true ? 'dm-warn' : 'dm-ok' },
|
|
474
|
+
last.stale === true ? '升级命令执行完成,但版本未前进或未达目标' : '升级完成,重启宿主后生效')
|
|
475
|
+
: h('span', { className: 'dm-error' }, '升级失败'
|
|
476
|
+
+ (last.code !== null && last.code !== undefined ? '(退出码 ' + last.code + ')' : '')
|
|
477
|
+
+ (Array.isArray(last.attempts) && last.attempts.length > 1 ? '(已尝试 ' + last.attempts.length + ' 次)' : '')
|
|
478
|
+
+ (last.timedOut ? ',已超时终止;安装可能只完成一半,重启前先确认命令需否重跑' : '')),
|
|
424
479
|
h('span', { className: 'dm-spacer' }),
|
|
425
|
-
last.ok ? h('button', {
|
|
480
|
+
last.ok && last.stale !== true ? h('button', {
|
|
426
481
|
className: 'dm-btn',
|
|
427
482
|
// 与 OpsCard 入口行为归一:appExit 缺失时必然 500,不得开放重启入口
|
|
428
483
|
disabled: props.restarting || !props.status.canRestart,
|
|
429
484
|
onClick: props.onRestart,
|
|
430
|
-
}, props.restartArmed
|
|
485
|
+
}, restartConfirmLabel(props.restartArmed, props.activeWorkTotal)) : null,
|
|
431
486
|
)
|
|
432
487
|
: null,
|
|
488
|
+
!upgrade.running && last && last.ok === true && last.stale === true
|
|
489
|
+
? h('div', { className: 'dm-row' },
|
|
490
|
+
h('span', { className: 'dm-warn' }, '磁盘版本未前进或未达目标: ' + (last.reason || '镜像可能滞后')))
|
|
491
|
+
: null,
|
|
433
492
|
!upgrade.running && last && !last.ok && last.stderrTail
|
|
434
493
|
? h('pre', { className: 'dm-pre' }, last.stderrTail)
|
|
435
494
|
: null,
|
|
436
495
|
)
|
|
437
496
|
}
|
|
438
497
|
|
|
498
|
+
// 重启确认按钮文案:armed 且有活跃计数时改「仍要重启」,点发即带 force 越过门控
|
|
499
|
+
// LOGIC-BEGIN restartConfirmLabel
|
|
500
|
+
function restartConfirmLabel(armed, activeWorkTotal) {
|
|
501
|
+
if (!armed) return '重启宿主'
|
|
502
|
+
return activeWorkTotal > 0 ? '仍要重启(' + activeWorkTotal + ' 项活跃工作)' : '确认重启'
|
|
503
|
+
}
|
|
504
|
+
// LOGIC-END restartConfirmLabel
|
|
505
|
+
|
|
439
506
|
// 运维区:两段式重启 + 托管环境说明。
|
|
440
507
|
function OpsCard(props) {
|
|
441
508
|
const status = props.status
|
|
@@ -446,11 +513,11 @@ function OpsCard(props) {
|
|
|
446
513
|
className: 'dm-btn dm-btn--danger',
|
|
447
514
|
disabled: !status.canRestart || props.restarting,
|
|
448
515
|
onClick: props.onRestart,
|
|
449
|
-
}, props.armed
|
|
516
|
+
}, restartConfirmLabel(props.armed, props.activeWorkTotal)),
|
|
450
517
|
!status.canRestart ? h('span', { className: 'dm-meta' }, '当前启动方式不支持就地重启') : null,
|
|
451
518
|
),
|
|
452
519
|
h('div', { className: 'dm-meta' },
|
|
453
|
-
'重启依赖进程管理器(pm2 / systemd /
|
|
520
|
+
'重启依赖进程管理器(pm2 / systemd / supervisord / Kubernetes / 容器等)自动拉起;手动终端启动的进程不会自动恢复。',
|
|
454
521
|
'重启后本页自动检测宿主恢复并刷新;若长时间未恢复请手动刷新。运行中的 agent 将被中断,会话已持久化,重开后可 resume。'),
|
|
455
522
|
)
|
|
456
523
|
}
|
|
@@ -466,6 +533,8 @@ function MaintainApp() {
|
|
|
466
533
|
// pid/bootAt 保持确认时刻基线不可被拍结果覆盖,否则重启判定恒 false;null=未在等待态
|
|
467
534
|
const restartPrevRef = useRef(null)
|
|
468
535
|
const restartPendingRef = useRef(false)
|
|
536
|
+
// 自动重启接管防重入:接管无 POST 在途语义(restartPendingRef),需独立一次性令牌
|
|
537
|
+
const autoRestartTakenRef = useRef(false)
|
|
469
538
|
|
|
470
539
|
function markBusy(key, value) {
|
|
471
540
|
setBusy((prev) => Object.assign({}, prev, { [key]: value }))
|
|
@@ -477,17 +546,35 @@ function MaintainApp() {
|
|
|
477
546
|
.catch((loadError) => { setError('读取状态失败:' + (loadError && loadError.message ? loadError.message : String(loadError))); return null })
|
|
478
547
|
}
|
|
479
548
|
|
|
480
|
-
//
|
|
549
|
+
// 挂载即订阅观察器快照,页面刷新落在升级进行中时恢复浮条与观察;
|
|
550
|
+
// 订阅回调(落定拍)与初始 load 快照(刷新落在调度窗口)两路同走接管守卫
|
|
481
551
|
useEffect(() => {
|
|
482
|
-
const unsubscribe = subscribeUpgradeStatus((
|
|
483
|
-
if (
|
|
552
|
+
const unsubscribe = subscribeUpgradeStatus((snapshot) => {
|
|
553
|
+
if (snapshot !== null) setStatus(snapshot)
|
|
554
|
+
takeOverAutoRestart(snapshot)
|
|
484
555
|
})
|
|
485
556
|
void load().then((next) => {
|
|
557
|
+
if (next !== null) takeOverAutoRestart(next)
|
|
486
558
|
if (next !== null && next.upgrade !== null && next.upgrade.running === true) ensureUpgradeWatch()
|
|
487
559
|
})
|
|
488
560
|
return unsubscribe
|
|
489
561
|
}, [])
|
|
490
562
|
|
|
563
|
+
// 自动重启接管守卫:快照带调度标记且未在等待/接管态时,以该快照为基线自动进入
|
|
564
|
+
// 重启等待(复用 restartTick 探测,无需用户点击);autoRestartTakenRef 防多拍重入
|
|
565
|
+
function takeOverAutoRestart(snapshot) {
|
|
566
|
+
if (snapshot === null || snapshot.autoRestartScheduled !== true) return
|
|
567
|
+
if (restartPendingRef.current === true || autoRestartTakenRef.current === true) return
|
|
568
|
+
autoRestartTakenRef.current = true
|
|
569
|
+
beginRestartWait({ lost: false, pid: snapshot.pid, bootAt: snapshot.bootAt, readyStreak: 0 })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function beginRestartWait(baseline) {
|
|
573
|
+
restartPrevRef.current = baseline
|
|
574
|
+
setError(null)
|
|
575
|
+
setRestarting(true)
|
|
576
|
+
}
|
|
577
|
+
|
|
491
578
|
// 重启判定与 core.mjs shouldReloadAfterRestart 同源:client 半区无法 import ESM,修改需两处同步。
|
|
492
579
|
// prev/next 均为 {lost,pid,bootAt} 快照:lost=经历失联(强信号);bootAt=宿主进程启动时刻,
|
|
493
580
|
// 变化即重启(容器 pid 恒 1 场景的唯一可靠信号);pid 比对为 bootAt 缺失时的退化路径
|
|
@@ -708,7 +795,7 @@ function MaintainApp() {
|
|
|
708
795
|
// 放在受理成功之后:明确回绝(409 互斥)时升级仍在跑,观察器必须存活
|
|
709
796
|
stopUpgradeWatch()
|
|
710
797
|
removeUpgradeFloat()
|
|
711
|
-
return post(RESTART_URL)
|
|
798
|
+
return post(RESTART_URL, activeWorkTotal > 0 ? { force: true } : undefined)
|
|
712
799
|
})
|
|
713
800
|
.catch((restartError) => {
|
|
714
801
|
if (restartPostLost(restartError)) {
|
|
@@ -720,6 +807,13 @@ function MaintainApp() {
|
|
|
720
807
|
// 有应答的明确回绝(409 升级互斥/500 能力缺失等):宿主未退出,不得进入等待轮询,
|
|
721
808
|
// 否则会凭"活宿主 + pageReady 恒真"误判已重启而整页刷新
|
|
722
809
|
setRestarting(false)
|
|
810
|
+
// 活跃工作回绝:保留确认态并回写计数(下一拍确认即可带 force)
|
|
811
|
+
if (restartError && restartError.payload && restartError.payload.items) {
|
|
812
|
+
setRestartArmed(true)
|
|
813
|
+
void load()
|
|
814
|
+
notify('存在活跃工作(共 ' + restartError.payload.items.total + ' 项),已保留确认态,再次点击将以 force 重启', 'error')
|
|
815
|
+
return
|
|
816
|
+
}
|
|
723
817
|
notify('重启失败:' + (restartError && restartError.message ? restartError.message : String(restartError)), 'error')
|
|
724
818
|
})
|
|
725
819
|
.then(() => { restartPendingRef.current = false })
|
|
@@ -732,6 +826,16 @@ function MaintainApp() {
|
|
|
732
826
|
error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
|
|
733
827
|
}
|
|
734
828
|
|
|
829
|
+
// 升级入口不可用即解除两段式待发:渲染时派生,不引入 effect。
|
|
830
|
+
// 不可用与 VersionCard 按钮 disabled 条件同源(升级进行中/残留锁/已最新/重启中)
|
|
831
|
+
const upgradeUnavailable = restarting
|
|
832
|
+
|| (status !== null && ((status.upgrade && status.upgrade.running === true)
|
|
833
|
+
|| status.upgradeLockHeld === true
|
|
834
|
+
|| status.verdict === VERDICT_UP_TO_DATE))
|
|
835
|
+
const upgradeArmedLive = upgradeArmed && !upgradeUnavailable
|
|
836
|
+
// 活跃工作计数:重启确认态显示计数并发 force 越过门控
|
|
837
|
+
const activeWorkTotal = status !== null && status.activeWork ? status.activeWork.total : 0
|
|
838
|
+
|
|
735
839
|
return h('div', { className: 'dm-panel' },
|
|
736
840
|
h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
|
|
737
841
|
h('div', { className: 'dm-head' },
|
|
@@ -746,7 +850,7 @@ function MaintainApp() {
|
|
|
746
850
|
h(VersionCard, {
|
|
747
851
|
status,
|
|
748
852
|
busy,
|
|
749
|
-
upgradeArmed,
|
|
853
|
+
upgradeArmed: upgradeArmedLive,
|
|
750
854
|
restartArmed,
|
|
751
855
|
restarting,
|
|
752
856
|
onRefresh,
|
|
@@ -762,8 +866,8 @@ function MaintainApp() {
|
|
|
762
866
|
onPollInterval,
|
|
763
867
|
onRegistryBase,
|
|
764
868
|
}),
|
|
765
|
-
h(UpgradeCard, { status, restartArmed, restarting, onRestart }),
|
|
766
|
-
h(OpsCard, { status, armed: restartArmed, restarting, onRestart }),
|
|
869
|
+
h(UpgradeCard, { status, restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
870
|
+
h(OpsCard, { status, armed: restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
767
871
|
)
|
|
768
872
|
}
|
|
769
873
|
|
package/src/core.mjs
CHANGED
|
@@ -86,6 +86,64 @@ export function judgeVersion({ currentVersion, tags, channel }) {
|
|
|
86
86
|
return { channelLatest, verdict, reason: null }
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// 运行/已装版本区分:磁盘版本严格领先运行版本即待重启生效;任一侧不可解析一律 false 不误报。
|
|
90
|
+
export function isVersionPendingRestart({ runningVersion, installedVersion }) {
|
|
91
|
+
return Boolean(parseSemver(runningVersion) && parseSemver(installedVersion) && gtSemver(installedVersion, runningVersion))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 升级后磁盘版本复读判定:stale=版本未前进(镜像滞后/静默未升)或未达通道目标。
|
|
95
|
+
// previous 未知只豁免"未前进"分支;目标缺失时无法证明未达标,宽松判 fresh 不误报。
|
|
96
|
+
export function judgeUpgradeFreshness({ previousVersion, installedVersion, channelLatest }) {
|
|
97
|
+
if (!parseSemver(installedVersion)) {
|
|
98
|
+
return { stale: true, reason: '升级后磁盘版本解析失败' + (installedVersion ? ': ' + installedVersion : '(读取失败)') }
|
|
99
|
+
}
|
|
100
|
+
if (parseSemver(previousVersion) && !gtSemver(installedVersion, previousVersion)) {
|
|
101
|
+
return { stale: true, reason: '磁盘版本未前进,升级可能静默失败或镜像滞后: ' + installedVersion }
|
|
102
|
+
}
|
|
103
|
+
if (parseSemver(channelLatest) && gtSemver(channelLatest, installedVersion)) {
|
|
104
|
+
return { stale: true, reason: '已升级但未达通道目标: ' + installedVersion + ' < ' + channelLatest }
|
|
105
|
+
}
|
|
106
|
+
return { stale: false, reason: null }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 升级失败分类 kind 常量;导出供重试循环与测试对拍
|
|
110
|
+
export const UPGRADE_FAIL_TRANSIENT_NETWORK = 'transient-network'
|
|
111
|
+
export const UPGRADE_FAIL_FILE_LOCKED = 'file-locked'
|
|
112
|
+
export const UPGRADE_FAIL_NPM_MISSING = 'npm-missing'
|
|
113
|
+
export const UPGRADE_FAIL_TIMEOUT = 'timeout'
|
|
114
|
+
export const UPGRADE_FAIL_UNKNOWN = 'unknown'
|
|
115
|
+
|
|
116
|
+
// 特征宽松:未命中一律归 unknown 不可重试,绝不误判重试不可重试类。
|
|
117
|
+
// 文件锁特征(npm error code EBUSY/EPERM 等 Windows 全局目录占用形态);
|
|
118
|
+
// ENOENT 与 rename/unlink 分行输出,故窗口跨行。
|
|
119
|
+
const FILE_LOCKED_PATTERN = /EBUSY|EPERM|ENOENT[\s\S]{0,200}(?:rename|unlink)|resource busy|being used by another process/i
|
|
120
|
+
const NPM_ERROR_MARKER = 'npm error'
|
|
121
|
+
// 命令未找到形态:cmd 与 sh 的报错文案 + spawn 失败裸 ENOENT(无 npm error 前缀)
|
|
122
|
+
const COMMAND_MISSING_PATTERN = /不是内部或外部命令|command not found|not found/i
|
|
123
|
+
const ENOENT_PATTERN = /\bENOENT\b/i
|
|
124
|
+
// 网络瞬断特征:常见 errno + socket/fetch 文案 + registry 5xx 形态(E503/HTTP 5xx)
|
|
125
|
+
const TRANSIENT_NETWORK_PATTERN = /ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|socket hang up|fetch failed|network|\bE5\d{2}\b|HTTP 5\d{2}/i
|
|
126
|
+
|
|
127
|
+
// 升级单次尝试失败分类:超时强杀最优先(超时绝不重试),其余按输出特征匹配。
|
|
128
|
+
// 合并 stderr 与 stdout 尾流:npm 的 code/syscall 行在 stderr,部分形态在 stdout。
|
|
129
|
+
export function classifyUpgradeFailure({ code, timedOut, stdoutTail, stderrTail } = {}) {
|
|
130
|
+
const text = String(stderrTail ?? '') + '\n' + String(stdoutTail ?? '')
|
|
131
|
+
if (timedOut === true) {
|
|
132
|
+
return { kind: UPGRADE_FAIL_TIMEOUT, retryable: false, reason: '升级命令超时被强制终止' }
|
|
133
|
+
}
|
|
134
|
+
if (FILE_LOCKED_PATTERN.test(text)) {
|
|
135
|
+
return { kind: UPGRADE_FAIL_FILE_LOCKED, retryable: true, reason: '全局目录文件被占用(杀毒/索引服务或并行进程)' }
|
|
136
|
+
}
|
|
137
|
+
const hasNpmErrorPrefix = text.includes(NPM_ERROR_MARKER)
|
|
138
|
+
if ((COMMAND_MISSING_PATTERN.test(text) || ENOENT_PATTERN.test(text)) && !hasNpmErrorPrefix) {
|
|
139
|
+
return { kind: UPGRADE_FAIL_NPM_MISSING, retryable: false, reason: '命令未找到,检查升级命令模板' }
|
|
140
|
+
}
|
|
141
|
+
if (TRANSIENT_NETWORK_PATTERN.test(text)) {
|
|
142
|
+
return { kind: UPGRADE_FAIL_TRANSIENT_NETWORK, retryable: true, reason: '网络瞬断或 registry 暂不可用' }
|
|
143
|
+
}
|
|
144
|
+
return { kind: UPGRADE_FAIL_UNKNOWN, retryable: false, reason: '未识别的失败形态' }
|
|
145
|
+
}
|
|
146
|
+
|
|
89
147
|
// 模板占位符执行时替换;模板允许不含占位符(用户整体自改命令),空模板拒绝。
|
|
90
148
|
// tag 经白名单校验:tag 名来自远端 registry 数据,拼入 shell 命令前单点拦截
|
|
91
149
|
// shell 元字符,封死"远端数据回流成命令"通路。
|