@mzzsfy/dsh-maintain 0.6.1 → 0.7.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 +5 -5
- package/package.json +1 -1
- package/src/client.js +105 -16
- package/src/core.mjs +16 -4
- package/src/index.js +81 -44
- package/test/dist-tags.test.mjs +36 -0
- package/test/parity.test.mjs +11 -0
- package/test/route.test.mjs +229 -51
- package/test/switch-guard.test.mjs +45 -0
- package/test/upgrade.test.mjs +21 -11
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
|
|
|
6
6
|
|
|
7
7
|
- 版本监测:host 启动即检查一次,按轮询间隔重复检查(内存快照,不持久化);当前版本与追踪通道最新版对比,落后即提示。运行版本(宿主启动时缓存)与已装版本(磁盘实时)分开统计与展示,落后判定以运行版本为准,已装版本领先运行版本时提示"重启后生效"。定时轮询为软依赖:宿主 timer 服务不可用时仅停用自动轮询,面板显示降级提示,手动检查与升级能力不受影响。
|
|
8
8
|
- 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
|
|
9
|
-
-
|
|
9
|
+
- 一键升级:点击升级弹出确认窗,窗内展示将执行的实际命令(`{tag}` 已替换为追踪通道)与"升级完成后自动重启宿主"勾选项(默认勾选,本次升级会话级生效,不持久化),确认后才触发;命令模板可在设置面板直接查看与编辑。升级期间串行化(含跨进程锁文件:宿主被外部重启后,残留升级子进程的锁在有效期内仍拒绝新升级),单次尝试超时 10 分钟强杀;升级后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面即可重启生效;继续运行路径下升级完成后自动重新检查版本。
|
|
10
10
|
- 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
|
|
11
|
-
-
|
|
12
|
-
- 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)
|
|
13
|
-
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm
|
|
11
|
+
- 升级结果校验:升级命令成功且宿主继续运行(手动直跑或本次未勾选自动重启)时复读磁盘版本对比——版本未前进(镜像滞后或静默失败)或未达通道目标版本时,面板与浮条标注 stale 提示;自动重启路径不做复读与 stale 判定(升级落定钩子禁止网络请求与磁盘读取),版本是否前进由重启后的启动检查呈现。运行版本已是通道最新时升级入口直接拒绝(409 防降级,面板按钮同步禁用),重装场景走命令行。超时强杀意味着安装可能只完成一半,重启宿主前先确认命令需否重跑。
|
|
12
|
+
- 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级确认弹窗勾选自动重启且命令成功(退出码 0)时,托管环境下延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启(弹窗内同步提示该环境约束);检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启。
|
|
13
|
+
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
|
|
14
14
|
- 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
|
|
15
15
|
- 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
|
|
16
16
|
- 操作反馈:检查更新、通道切换、面板保存与升级触发的失败经全站浮出通知(@mzzsfy/dsh-toast)提示;页面加载失败与重启超时等状态提示仍在面板内展示。
|
|
@@ -42,7 +42,7 @@ maintain:
|
|
|
42
42
|
registryBase: https://registry.npmjs.org # 官方源不可达时可改为镜像地址
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效 settings.yaml,热生效)
|
|
45
|
+
升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效 settings.yaml,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
|
|
46
46
|
|
|
47
47
|
## 重启须知
|
|
48
48
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -67,6 +67,29 @@ const CSS = [
|
|
|
67
67
|
'.dm-notice--ok { color:var(--dsw-alias-state-success-primary, #1a9e55); }',
|
|
68
68
|
'.dm-pre { margin:0; font-family:ui-monospace, Consolas, monospace; font-size:11px; line-height:1.6; white-space:pre-wrap;',
|
|
69
69
|
' word-break:break-all; color:var(--dsw-alias-label-secondary); max-height:120px; overflow:auto; }',
|
|
70
|
+
// ---- 布尔开关:规约形态(track 胶囊 + thumb 圆点,状态锚定 input) ----
|
|
71
|
+
'.dm-switch { display:inline-flex; align-items:center; gap:8px; cursor:pointer; position:relative; }',
|
|
72
|
+
'.dm-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
|
|
73
|
+
'.dm-switch__track { width:30px; height:16px; border-radius:999px; flex:none; position:relative;',
|
|
74
|
+
' background:var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); transition:background 0.15s ease; }',
|
|
75
|
+
'.dm-switch__thumb { position:absolute; top:2px; left:2px; width:12px; height:12px; border-radius:50%;',
|
|
76
|
+
' background:#fff; transition:left 0.15s ease; }',
|
|
77
|
+
'.dm-switch input[type="checkbox"]:checked + .dm-switch__track { background:var(--dsw-alias-state-success-primary, #1a9e55); }',
|
|
78
|
+
'.dm-switch input[type="checkbox"]:checked + .dm-switch__track .dm-switch__thumb { left:16px; }',
|
|
79
|
+
'.dm-switch input[type="checkbox"]:focus-visible + .dm-switch__track { outline:2px solid var(--dsw-alias-brand-primary, #4d6bfe); outline-offset:2px; }',
|
|
80
|
+
'.dm-switch input[type="checkbox"]:disabled + .dm-switch__track { opacity:0.45; cursor:default; }',
|
|
81
|
+
'.dm-switch:hover { opacity:0.85; }',
|
|
82
|
+
// 文字标签在 DOM 中排 track 之后(保 input+track 紧邻兄弟,状态选择器才生效),视觉以 order 提前
|
|
83
|
+
'.dm-switch .dm-row__label { order:-1; }',
|
|
84
|
+
// ---- 升级确认弹窗:遮罩 + 居中窗体 ----
|
|
85
|
+
'.dm-dialog__mask { position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:9999;',
|
|
86
|
+
' display:flex; align-items:center; justify-content:center; padding:16px; }',
|
|
87
|
+
'.dm-dialog { background:var(--dsw-alias-bg-layer-1, #fff); color:inherit; border-radius:12px; padding:16px;',
|
|
88
|
+
' width:480px; max-width:100%; max-height:80vh; overflow:auto; display:flex; flex-direction:column; gap:10px;',
|
|
89
|
+
' box-shadow:0 8px 32px rgba(0,0,0,0.25); }',
|
|
90
|
+
'.dm-dialog__hint { color:var(--dsw-alias-label-secondary); font-size:12px; }',
|
|
91
|
+
'.dm-dialog__section { display:flex; flex-direction:column; gap:6px; padding:8px 0; border-top:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); }',
|
|
92
|
+
'.dm-dialog__outcome { color:var(--dsw-alias-label-secondary); font-size:12px; line-height:1.6; }',
|
|
70
93
|
].join('\n')
|
|
71
94
|
|
|
72
95
|
const STATUS_URL = '/api/maintain/status'
|
|
@@ -128,6 +151,10 @@ function restartPostLost(error) {
|
|
|
128
151
|
// test/parity.test.mjs 按 const 名正则提取对拍,修改任一侧必须同步。
|
|
129
152
|
const VERDICT_OUTDATED = 'outdated'
|
|
130
153
|
const VERDICT_UP_TO_DATE = 'up-to-date'
|
|
154
|
+
// 与 host runtime.mjs RUNTIME_KINDS.MANUAL_START 镜像(parity 对拍,单侧改名即测试失败)
|
|
155
|
+
const RUNTIME_KIND_MANUAL = 'manual-start-likely'
|
|
156
|
+
// 与 host AUTO_RESTART_DELAY_MS 镜像(parity 对拍):弹窗文案引用,勿手抄数字
|
|
157
|
+
const AUTO_RESTART_DELAY_SEC = 3
|
|
131
158
|
const VERDICT_UNKNOWN = 'unknown'
|
|
132
159
|
|
|
133
160
|
// 非 2xx 应答抛带 status 与完整 payload 的错误对象:调用方据此区分"活宿主明确回绝"(有 status)
|
|
@@ -152,6 +179,11 @@ async function api(url, options) {
|
|
|
152
179
|
return payload
|
|
153
180
|
}
|
|
154
181
|
|
|
182
|
+
// 状态预取:模块加载即页面加载时发起,首次进入面板不再与宿主的初始请求风暴
|
|
183
|
+
// (会话树全量扫描等单请求秒级负载)排队;预取只服务首次挂载,消费即置空,
|
|
184
|
+
// 后续挂载保持现拉,不因复用旧快照而退化
|
|
185
|
+
let statusPrefetch = api(STATUS_URL).catch(() => null)
|
|
186
|
+
|
|
155
187
|
function post(url, body) {
|
|
156
188
|
return api(url, { method: 'POST', body: body === undefined ? '{}' : JSON.stringify(body) })
|
|
157
189
|
}
|
|
@@ -363,6 +395,21 @@ function EditRow(props) {
|
|
|
363
395
|
)
|
|
364
396
|
}
|
|
365
397
|
|
|
398
|
+
// 布尔开关:原生 checkbox 保可访问性,视觉为 track 胶囊 + thumb 圆点(规约形态);
|
|
399
|
+
// input 与 track 必须保持紧邻兄弟(状态选择器为紧邻组合器),文字标签排最后,视觉经 order 提前
|
|
400
|
+
function Switch(props) {
|
|
401
|
+
return h('label', { className: 'dm-switch', title: props.title },
|
|
402
|
+
h('input', {
|
|
403
|
+
type: 'checkbox',
|
|
404
|
+
checked: props.checked === true,
|
|
405
|
+
disabled: props.disabled === true,
|
|
406
|
+
onChange: (e) => props.onChange(e.target.checked),
|
|
407
|
+
}),
|
|
408
|
+
h('span', { className: 'dm-switch__track' }, h('span', { className: 'dm-switch__thumb' })),
|
|
409
|
+
props.label ? h('span', { className: 'dm-row__label' }, props.label) : null,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
366
413
|
// 版本区:当前版本、通道切换、通道最新版、结论、检查时间与错误、刷新与升级、升级命令编辑。
|
|
367
414
|
function VersionCard(props) {
|
|
368
415
|
const status = props.status
|
|
@@ -389,7 +436,7 @@ function VersionCard(props) {
|
|
|
389
436
|
|| status.verdict === VERDICT_UP_TO_DATE,
|
|
390
437
|
title: status.verdict === VERDICT_UP_TO_DATE ? '当前已是通道最新版,无需升级;重装请走命令行' : undefined,
|
|
391
438
|
onClick: props.onUpgrade,
|
|
392
|
-
},
|
|
439
|
+
}, '升级'),
|
|
393
440
|
),
|
|
394
441
|
status.restartPending === true
|
|
395
442
|
? h('div', { className: 'dm-notice dm-notice--warn' },
|
|
@@ -522,11 +569,51 @@ function OpsCard(props) {
|
|
|
522
569
|
)
|
|
523
570
|
}
|
|
524
571
|
|
|
572
|
+
// 升级确认弹窗:展示将执行的命令({tag} 已替换为追踪通道)与自动重启勾选,确认才触发。
|
|
573
|
+
// 自动重启是会话级选择(不持久化),勾/不勾的后果在窗内明示:
|
|
574
|
+
// 勾选=命令成功后宿主延迟 3 秒退出,由进程管理器拉起新版本,页面随宿主恢复自动刷新;
|
|
575
|
+
// 不勾选=宿主继续运行旧版本,面板提示"重启宿主后生效",需手动点重启按钮
|
|
576
|
+
function UpgradeDialog(props) {
|
|
577
|
+
// 每次打开都重新挂载(MaintainApp 条件渲染),默认勾选随挂载重置
|
|
578
|
+
const [autoRestart, setAutoRestart] = useState(true)
|
|
579
|
+
const status = props.status
|
|
580
|
+
const template = typeof status.upgradeTemplate === 'string' && status.upgradeTemplate.length > 0
|
|
581
|
+
? status.upgradeTemplate
|
|
582
|
+
: DEFAULT_UPGRADE_TEMPLATE
|
|
583
|
+
const command = template.split('{tag}').join(status.channel)
|
|
584
|
+
return h('div', { className: 'dm-dialog__mask', onClick: props.onCancel },
|
|
585
|
+
h('div', { className: 'dm-dialog', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
586
|
+
h('div', { className: 'dm-card__title' }, '确认升级'),
|
|
587
|
+
h('div', { className: 'dm-dialog__hint' }, '将执行以下命令(升级期间页面可离开,进度由右下角浮条提示):'),
|
|
588
|
+
h('pre', { className: 'dm-pre' }, command),
|
|
589
|
+
h('div', { className: 'dm-dialog__section' },
|
|
590
|
+
h(Switch, {
|
|
591
|
+
label: '升级完成后自动重启宿主',
|
|
592
|
+
checked: autoRestart,
|
|
593
|
+
onChange: setAutoRestart,
|
|
594
|
+
}),
|
|
595
|
+
h('div', { className: 'dm-dialog__outcome' },
|
|
596
|
+
autoRestart
|
|
597
|
+
? '已勾选:升级命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起新版本,本页随宿主恢复自动刷新。升级落定后宿主不再执行任何网络请求与磁盘读取。'
|
|
598
|
+
: '未勾选:升级完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到新版本。'),
|
|
599
|
+
status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
|
|
600
|
+
? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
|
|
601
|
+
: null,
|
|
602
|
+
),
|
|
603
|
+
h('div', { className: 'dm-row' },
|
|
604
|
+
h('span', { className: 'dm-spacer' }),
|
|
605
|
+
h('button', { className: 'dm-btn', onClick: props.onCancel }, '取消'),
|
|
606
|
+
h('button', { className: 'dm-btn', onClick: () => props.onConfirm(autoRestart) }, '开始升级'),
|
|
607
|
+
),
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
525
612
|
function MaintainApp() {
|
|
526
613
|
const [status, setStatus] = useState(null)
|
|
527
614
|
const [error, setError] = useState(null)
|
|
528
615
|
const [busy, setBusy] = useState({})
|
|
529
|
-
const [
|
|
616
|
+
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false)
|
|
530
617
|
const [restartArmed, setRestartArmed] = useState(false)
|
|
531
618
|
const [restarting, setRestarting] = useState(false)
|
|
532
619
|
// 重启探测的跨拍状态快照 {lost,pid,bootAt,readyStreak}:lost 与 readyStreak 随拍更新,
|
|
@@ -541,7 +628,11 @@ function MaintainApp() {
|
|
|
541
628
|
}
|
|
542
629
|
|
|
543
630
|
function load() {
|
|
544
|
-
|
|
631
|
+
// 首次挂载消费预取快照(缺失/失败回退现拉),此后每次挂载现拉
|
|
632
|
+
const primed = statusPrefetch
|
|
633
|
+
statusPrefetch = null
|
|
634
|
+
const base = primed !== null ? primed.then((snap) => (snap !== null ? snap : api(STATUS_URL))) : api(STATUS_URL)
|
|
635
|
+
return base
|
|
545
636
|
.then((next) => { setStatus(next); setError(null); return next })
|
|
546
637
|
.catch((loadError) => { setError('读取状态失败:' + (loadError && loadError.message ? loadError.message : String(loadError))); return null })
|
|
547
638
|
}
|
|
@@ -742,15 +833,10 @@ function MaintainApp() {
|
|
|
742
833
|
submitEdit(REGISTRY_BASE_URL, { base }, 'registryBase', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
|
|
743
834
|
}
|
|
744
835
|
|
|
745
|
-
function onUpgrade() {
|
|
746
|
-
|
|
747
|
-
setUpgradeArmed(true)
|
|
748
|
-
setRestartArmed(false)
|
|
749
|
-
return
|
|
750
|
-
}
|
|
751
|
-
setUpgradeArmed(false)
|
|
836
|
+
function onUpgrade(autoRestart) {
|
|
837
|
+
setUpgradeDialogOpen(false)
|
|
752
838
|
markBusy('upgrade', true)
|
|
753
|
-
post(UPGRADE_URL)
|
|
839
|
+
post(UPGRADE_URL, { autoRestart: autoRestart === true })
|
|
754
840
|
.then((next) => {
|
|
755
841
|
setStatus(next); setError(null)
|
|
756
842
|
// 升级转后台执行:观察器接管状态跟踪与完成提示,页面可离开
|
|
@@ -764,7 +850,6 @@ function MaintainApp() {
|
|
|
764
850
|
if (restartPendingRef.current) return
|
|
765
851
|
if (!restartArmed) {
|
|
766
852
|
setRestartArmed(true)
|
|
767
|
-
setUpgradeArmed(false)
|
|
768
853
|
return
|
|
769
854
|
}
|
|
770
855
|
setRestartArmed(false)
|
|
@@ -826,13 +911,13 @@ function MaintainApp() {
|
|
|
826
911
|
error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
|
|
827
912
|
}
|
|
828
913
|
|
|
829
|
-
//
|
|
914
|
+
// 升级入口不可用即关闭确认弹窗:渲染时派生,不引入 effect。
|
|
830
915
|
// 不可用与 VersionCard 按钮 disabled 条件同源(升级进行中/残留锁/已最新/重启中)
|
|
831
916
|
const upgradeUnavailable = restarting
|
|
832
917
|
|| (status !== null && ((status.upgrade && status.upgrade.running === true)
|
|
833
918
|
|| status.upgradeLockHeld === true
|
|
834
919
|
|| status.verdict === VERDICT_UP_TO_DATE))
|
|
835
|
-
const
|
|
920
|
+
const upgradeDialogOpenLive = upgradeDialogOpen && !upgradeUnavailable
|
|
836
921
|
// 活跃工作计数:重启确认态显示计数并发 force 越过门控
|
|
837
922
|
const activeWorkTotal = status !== null && status.activeWork ? status.activeWork.total : 0
|
|
838
923
|
|
|
@@ -850,13 +935,12 @@ function MaintainApp() {
|
|
|
850
935
|
h(VersionCard, {
|
|
851
936
|
status,
|
|
852
937
|
busy,
|
|
853
|
-
upgradeArmed: upgradeArmedLive,
|
|
854
938
|
restartArmed,
|
|
855
939
|
restarting,
|
|
856
940
|
onRefresh,
|
|
857
941
|
onChannel,
|
|
858
942
|
onTemplateSave,
|
|
859
|
-
onUpgrade,
|
|
943
|
+
onUpgrade: () => { setRestartArmed(false); setUpgradeDialogOpen(true) },
|
|
860
944
|
onRestart,
|
|
861
945
|
}),
|
|
862
946
|
h(SettingsCard, {
|
|
@@ -868,6 +952,11 @@ function MaintainApp() {
|
|
|
868
952
|
}),
|
|
869
953
|
h(UpgradeCard, { status, restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
870
954
|
h(OpsCard, { status, armed: restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
955
|
+
upgradeDialogOpenLive ? h(UpgradeDialog, {
|
|
956
|
+
status,
|
|
957
|
+
onCancel: () => setUpgradeDialogOpen(false),
|
|
958
|
+
onConfirm: onUpgrade,
|
|
959
|
+
}) : null,
|
|
871
960
|
)
|
|
872
961
|
}
|
|
873
962
|
|
package/src/core.mjs
CHANGED
|
@@ -241,6 +241,15 @@ export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs
|
|
|
241
241
|
return tags
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// 运行版本事实源:运行进程 argv[1] 指向运行宿主自己的 bin.js(<包根>/lib/bin.js),
|
|
245
|
+
// dirname 后上一级即包根,其 package.json 就是运行宿主清单。多副本共存时全局布局
|
|
246
|
+
// 候选与 createRequire 解析都会命中其他副本(全局 0.1.5 + 副本跑旧版实测),只有
|
|
247
|
+
// argv[1] 与运行进程绑定,列为最高优先级候选。
|
|
248
|
+
export function runningHostCandidate({ argv1, pathImpl = win32 }) {
|
|
249
|
+
if (!argv1 || typeof argv1 !== 'string') return null
|
|
250
|
+
return pathImpl.join(pathImpl.dirname(argv1), '..', 'package.json')
|
|
251
|
+
}
|
|
252
|
+
|
|
244
253
|
// npm 全局布局下宿主包清单位置的候选序列;win 与 posix 目录结构不同,按声明平台选实现,与宿主 OS 解耦。
|
|
245
254
|
export function hostPackageCandidates({ execPath, platform }) {
|
|
246
255
|
const pathImpl = platform === 'win32' ? win32 : posix
|
|
@@ -251,17 +260,20 @@ export function hostPackageCandidates({ execPath, platform }) {
|
|
|
251
260
|
return [pathImpl.join(globalDir, '@deepseek-ai', 'dsh', 'package.json')]
|
|
252
261
|
}
|
|
253
262
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
export async function resolveHostVersion({ execPath, platform, readFileImpl = readFile, resolveImpl }) {
|
|
263
|
+
// 宿主实际安装版本:运行宿主候选(argv[1] 推导)优先,再按全局布局候选,最后退
|
|
264
|
+
// createRequire 解析(插件可能随宿主树部署)。全部失败返回 null,面板显示未知,不影响宿主。
|
|
265
|
+
export async function resolveHostVersion({ execPath, platform, argv1, readFileImpl = readFile, resolveImpl }) {
|
|
266
|
+
const pathImpl = platform === 'win32' ? win32 : posix
|
|
267
|
+
const running = runningHostCandidate({ argv1, pathImpl })
|
|
257
268
|
const candidates = hostPackageCandidates({ execPath, platform })
|
|
269
|
+
const ordered = running ? [running].concat(candidates) : candidates
|
|
258
270
|
let requireResolved = null
|
|
259
271
|
try {
|
|
260
272
|
requireResolved = resolveImpl ? resolveImpl(TARGET_PACKAGE + '/package.json') : createRequire(import.meta.url)(TARGET_PACKAGE + '/package.json')
|
|
261
273
|
} catch {
|
|
262
274
|
requireResolved = null
|
|
263
275
|
}
|
|
264
|
-
for (const candidate of requireResolved ?
|
|
276
|
+
for (const candidate of requireResolved ? ordered.concat(requireResolved) : ordered) {
|
|
265
277
|
try {
|
|
266
278
|
const parsed = JSON.parse(await readFileImpl(candidate, 'utf8'))
|
|
267
279
|
if (parsed && typeof parsed.version === 'string' && parsed.version.length > 0) return parsed.version
|
package/src/index.js
CHANGED
|
@@ -41,7 +41,7 @@ export const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000
|
|
|
41
41
|
// 响应发出到执行退出的延迟:保证浏览器收到 200 并进入重启等待态,进程才离场;
|
|
42
42
|
// 导出仅供测试计算延迟窗口等待时长
|
|
43
43
|
export const RESTART_DELAY_MS = 2 * 1000
|
|
44
|
-
//
|
|
44
|
+
// 升级成功后自动重启的延迟:给浮条终态与观察器接管一拍时间,观察器轮询可赶上;
|
|
45
45
|
// 导出仅供测试计算延迟窗口等待时长
|
|
46
46
|
export const AUTO_RESTART_DELAY_MS = 3 * 1000
|
|
47
47
|
// 轮询底层计时粒度;导出仅供 parity 测试与 client 提示文案对拍
|
|
@@ -74,8 +74,8 @@ const audit = (endpoint, outcome, extra) => {
|
|
|
74
74
|
|
|
75
75
|
// 活跃工作检测:agents/jobs/terminals 全软依赖方法面守卫,缺失或异常即降级放行(fail-open,
|
|
76
76
|
// 不因安全网缺失死锁重启)。terminals 主路径逐 agent 的 realm 作用域解析(PTY 注册表随
|
|
77
|
-
// agent 挂载且 isolate,共享根作用域因 realm
|
|
78
|
-
//
|
|
77
|
+
// agent 挂载且 isolate,共享根作用域因 realm 隔离恒缺,仅兜底;无 agent 时服务面
|
|
78
|
+
// 不可见属常态,不计降级);sessions 不作活跃指标;禁止顶层 inject(阻塞装载)。导出仅供测试
|
|
79
79
|
// 降级留痕按服务键进程首见一次:status 每拍重检,不限频会刷屏
|
|
80
80
|
const degradedWarned = new Set()
|
|
81
81
|
|
|
@@ -145,7 +145,9 @@ export function collectActiveWork(ctx) {
|
|
|
145
145
|
} catch (error) {
|
|
146
146
|
degrade('terminals', '根探测异常: ' + (error && error.message ? error.message : String(error)))
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
// 服务面随 agent realm 挂载:无 agent 时不可见属常态(无对象即无终端),不计降级;
|
|
149
|
+
// 有 agent 而探不到服务面(可探性缺失或面缺失)属异常,降级留痕
|
|
150
|
+
if (!sawTerminalsService && agentList.length > 0) degrade('terminals', 'terminals 服务面不可用')
|
|
149
151
|
} catch (error) {
|
|
150
152
|
degrade('terminals', error && error.message ? error.message : String(error))
|
|
151
153
|
}
|
|
@@ -202,12 +204,14 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
|
|
|
202
204
|
return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
|
|
203
205
|
}
|
|
204
206
|
|
|
205
|
-
//
|
|
206
|
-
// 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart
|
|
207
|
-
|
|
207
|
+
// 升级后自动重启守卫:四条件缺一不可——成功、非手动直跑、本次勾选 enabled、appExit 可用。
|
|
208
|
+
// 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引,
|
|
209
|
+
// 该指引是环境约束事实,不受勾选影响;enabled 由升级路由严格 boolean 校验保证
|
|
210
|
+
// 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
|
|
211
|
+
export function judgeAutoRestart({ ok, runtimeKind, hasExit, enabled }) {
|
|
208
212
|
if (ok !== true) return { schedule: false, requiresManualRestart: false }
|
|
209
|
-
if (stale === true) return { schedule: false, requiresManualRestart: false }
|
|
210
213
|
if (runtimeKind === RUNTIME_KINDS.MANUAL_START) return { schedule: false, requiresManualRestart: true }
|
|
214
|
+
if (enabled !== true) return { schedule: false, requiresManualRestart: false }
|
|
211
215
|
if (hasExit !== true) return { schedule: false, requiresManualRestart: false }
|
|
212
216
|
return { schedule: true, requiresManualRestart: false }
|
|
213
217
|
}
|
|
@@ -361,6 +365,7 @@ export function apply(ctx) {
|
|
|
361
365
|
return resolveHostVersion({
|
|
362
366
|
execPath: process.execPath,
|
|
363
367
|
platform: process.platform,
|
|
368
|
+
argv1: process.argv[1],
|
|
364
369
|
readFileImpl: readFile,
|
|
365
370
|
resolveImpl: undefined,
|
|
366
371
|
})
|
|
@@ -434,7 +439,8 @@ export function apply(ctx) {
|
|
|
434
439
|
return
|
|
435
440
|
}
|
|
436
441
|
const dispose = timerCtx.interval(() => {
|
|
437
|
-
|
|
442
|
+
// restartScheduled 窗口内不再发起检查:关机延迟期间的网络请求与磁盘读取都是半写状态风险
|
|
443
|
+
if (restartScheduled || checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
|
|
438
444
|
runCheck().then(scheduleNext, scheduleNext)
|
|
439
445
|
}, TICK_MS)
|
|
440
446
|
ctx.effect(() => dispose, 'dsh-maintain poll interval')
|
|
@@ -480,7 +486,7 @@ export function apply(ctx) {
|
|
|
480
486
|
}
|
|
481
487
|
}
|
|
482
488
|
|
|
483
|
-
function triggerUpgrade() {
|
|
489
|
+
function triggerUpgrade(autoRestart) {
|
|
484
490
|
const config = readSettings(ctx)
|
|
485
491
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
486
492
|
const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
|
|
@@ -505,8 +511,8 @@ export function apply(ctx) {
|
|
|
505
511
|
// running 即串行化门闩:路由检查与本处置位之间无 await,单线程下无竞态窗口
|
|
506
512
|
upgrade = { running: true, last }
|
|
507
513
|
writeUpgradeLock(last.startedAt)
|
|
508
|
-
audit('upgrade', 'triggered', 'command=' + singleLine(command))
|
|
509
|
-
void performUpgrade(command, last, config.channel)
|
|
514
|
+
audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
|
|
515
|
+
void performUpgrade(command, last, config.channel, autoRestart === true)
|
|
510
516
|
}
|
|
511
517
|
|
|
512
518
|
// 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
|
|
@@ -517,20 +523,24 @@ export function apply(ctx) {
|
|
|
517
523
|
} catch { /* 锁不可写仅损失跨进程防护,内存门闩仍生效 */ }
|
|
518
524
|
}
|
|
519
525
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
function
|
|
526
|
+
// 宿主退出调度唯一入口:手动重启(HTTP 入口)与升级落定(内部入口)共用,
|
|
527
|
+
// 置双向互斥标记、留审计、延迟 exit(0)。调度窗口内升级被 409,手动重启幂等
|
|
528
|
+
function scheduleHostExit({ detail, autoRestart = false, delayMs }) {
|
|
523
529
|
restartScheduled = true
|
|
524
|
-
autoRestartScheduled = true
|
|
525
|
-
audit('restart', 'scheduled', '
|
|
526
|
-
|
|
530
|
+
if (autoRestart === true) autoRestartScheduled = true
|
|
531
|
+
audit('restart', autoRestart === true ? 'scheduled' : 'triggered', detail + ' delayMs=' + delayMs)
|
|
532
|
+
// 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
|
|
533
|
+
// 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
|
|
534
|
+
setTimeout(() => exit(0), delayMs)
|
|
527
535
|
}
|
|
528
536
|
|
|
529
|
-
async function performUpgrade(command, last, channel) {
|
|
537
|
+
async function performUpgrade(command, last, channel, autoRestart) {
|
|
538
|
+
// settle 声明在 try 外:继续运行路径的版本复读在 finally 中消费它
|
|
539
|
+
let settle = null
|
|
530
540
|
try {
|
|
531
|
-
//
|
|
541
|
+
// 触发前快照磁盘版本,继续运行路径复读时以此为对比基线
|
|
532
542
|
last.previousVersion = await resolveCurrentHostVersion()
|
|
533
|
-
|
|
543
|
+
settle = await runUpgradeWithRetry({
|
|
534
544
|
command,
|
|
535
545
|
onAttemptStart: () => writeUpgradeLock(Date.now()),
|
|
536
546
|
})
|
|
@@ -547,20 +557,11 @@ export function apply(ctx) {
|
|
|
547
557
|
stdoutTail: settle.stdoutTail,
|
|
548
558
|
stderrTail: settle.stderrTail,
|
|
549
559
|
})
|
|
550
|
-
if (settle.ok) {
|
|
551
|
-
last.installedVersion = await resolveCurrentHostVersion()
|
|
552
|
-
const tags = snapshot.tags
|
|
553
|
-
const freshness = judgeUpgradeFreshness({
|
|
554
|
-
previousVersion: last.previousVersion,
|
|
555
|
-
installedVersion: last.installedVersion,
|
|
556
|
-
channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
|
|
557
|
-
})
|
|
558
|
-
last.stale = freshness.stale
|
|
559
|
-
last.reason = freshness.reason
|
|
560
|
-
}
|
|
561
560
|
} catch (error) {
|
|
562
561
|
last.error = error && error.message ? error.message : String(error)
|
|
563
562
|
} finally {
|
|
563
|
+
// 关机路径经 return 提前离场会吞掉 finally 内新抛异常:本块各消费点已 try/catch
|
|
564
|
+
// 防御(删锁/复读/runCheck),后续修改必须保持该契约,勿在此块新增可抛调用
|
|
564
565
|
if (last.finishedAt === null) last.finishedAt = Date.now()
|
|
565
566
|
upgrade = { running: false, last }
|
|
566
567
|
// stillRunning(强杀后进程树疑似仍在写全局目录)时保留锁文件,由过期机制收敛,
|
|
@@ -580,14 +581,33 @@ export function apply(ctx) {
|
|
|
580
581
|
+ (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
|
|
581
582
|
audit('upgrade', last.ok === true ? 'ok' : 'failed',
|
|
582
583
|
'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
|
|
583
|
-
//
|
|
584
|
+
// 关机前置判定只取内存输入(启动期缓存的运行环境 + 本次会话勾选):升级命令刚
|
|
585
|
+
// 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
|
|
584
586
|
await runtimeEnvReady
|
|
585
|
-
const decision = judgeAutoRestart({ ok: last.ok === true,
|
|
587
|
+
const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
586
588
|
if (decision.requiresManualRestart === true) last.requiresManualRestart = true
|
|
587
589
|
if (decision.schedule === true) {
|
|
590
|
+
// 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
|
|
588
591
|
// last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
|
|
589
592
|
last.autoRestartScheduled = true
|
|
590
|
-
|
|
593
|
+
scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
// 继续运行路径(失败/手动直跑/本次未勾选自动重启):复读磁盘版本标 stale,版本未前进提示保留
|
|
597
|
+
if (settle !== null && settle.ok) {
|
|
598
|
+
// 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
|
|
599
|
+
// 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
|
|
600
|
+
try {
|
|
601
|
+
last.installedVersion = await resolveCurrentHostVersion()
|
|
602
|
+
const tags = snapshot.tags
|
|
603
|
+
const freshness = judgeUpgradeFreshness({
|
|
604
|
+
previousVersion: last.previousVersion,
|
|
605
|
+
installedVersion: last.installedVersion,
|
|
606
|
+
channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
|
|
607
|
+
})
|
|
608
|
+
last.stale = freshness.stale
|
|
609
|
+
last.reason = freshness.reason
|
|
610
|
+
} catch { /* 复读失败仅损失 stale 提示,不覆盖升级成功结果 */ }
|
|
591
611
|
}
|
|
592
612
|
// 升级结束后自动重新检查版本并重排轮询(命令可能改了本地版本)
|
|
593
613
|
runCheck().then(scheduleNext, scheduleNext)
|
|
@@ -606,6 +626,10 @@ export function apply(ctx) {
|
|
|
606
626
|
{
|
|
607
627
|
path: API_PATHS.REFRESH,
|
|
608
628
|
handler: route('POST', WRITE, async (req, res) => {
|
|
629
|
+
if (restartScheduled) {
|
|
630
|
+
sendJson(res, 409, { error: '重启已调度,禁止检查更新' })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
609
633
|
await drainInFlightThenCheck()
|
|
610
634
|
scheduleNext()
|
|
611
635
|
sendJson(res, 200, await currentStatus())
|
|
@@ -698,6 +722,11 @@ export function apply(ctx) {
|
|
|
698
722
|
return
|
|
699
723
|
}
|
|
700
724
|
await settings.update(NAMESPACE, { registryBase: base })
|
|
725
|
+
if (restartScheduled) {
|
|
726
|
+
// 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
|
|
727
|
+
sendJson(res, 200, await currentStatus())
|
|
728
|
+
return
|
|
729
|
+
}
|
|
701
730
|
await drainInFlightThenCheck()
|
|
702
731
|
scheduleNext()
|
|
703
732
|
sendJson(res, 200, await currentStatus())
|
|
@@ -706,6 +735,15 @@ export function apply(ctx) {
|
|
|
706
735
|
{
|
|
707
736
|
path: API_PATHS.UPGRADE,
|
|
708
737
|
handler: route('POST', WRITE, async (req, res) => {
|
|
738
|
+
// body 校验必须在全部门控之前:门控到 triggerUpgrade 置位之间不得插入 await,
|
|
739
|
+
// 否则并发请求双双通过 409 门控,两个升级进程并发写全局目录
|
|
740
|
+
// 自动重启为确认弹窗的会话级勾选,严格 boolean:缺失/宽转一律拒绝,防静默翻转关机行为
|
|
741
|
+
const rawBody = await readBody(req)
|
|
742
|
+
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
743
|
+
if (typeof requestBody.autoRestart !== 'boolean') {
|
|
744
|
+
sendJson(res, 400, { error: 'autoRestart 必须是 boolean' })
|
|
745
|
+
return
|
|
746
|
+
}
|
|
709
747
|
// 与重启调度双向互斥:restartScheduled 置位到 exit(0) 执行的窗口内触发升级,
|
|
710
748
|
// detached 孤儿会与新宿主交错
|
|
711
749
|
if (upgrade.running || restartScheduled) {
|
|
@@ -737,13 +775,18 @@ export function apply(ctx) {
|
|
|
737
775
|
})
|
|
738
776
|
return
|
|
739
777
|
}
|
|
740
|
-
triggerUpgrade()
|
|
778
|
+
triggerUpgrade(requestBody.autoRestart)
|
|
741
779
|
sendJson(res, 200, await currentStatus())
|
|
742
780
|
}),
|
|
743
781
|
},
|
|
744
782
|
{
|
|
745
783
|
path: API_PATHS.RESTART,
|
|
746
784
|
handler: route('POST', WRITE, async (req, res) => {
|
|
785
|
+
// body 读取必须在全部门控之前:幂等检查到 scheduleHostExit 置位之间不得插入
|
|
786
|
+
// await,否则并发请求双双越过幂等门,双重调度 exit
|
|
787
|
+
const rawBody = await readBody(req)
|
|
788
|
+
// 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
|
|
789
|
+
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
747
790
|
// 与升级门闩互斥:升级子进程经 detached+unref 存活于宿主死后,
|
|
748
791
|
// 重启后新宿主门闩归零会放行第二次升级,双 npm install 并发写全局目录
|
|
749
792
|
if (upgrade.running) {
|
|
@@ -762,9 +805,6 @@ export function apply(ctx) {
|
|
|
762
805
|
}
|
|
763
806
|
// 活跃工作门控:重启可被 force 越过(升级后自动重启链路无法等待人工确认,
|
|
764
807
|
// 对齐 dsh-service:升级不可越/重启 force 可越)
|
|
765
|
-
const rawBody = await readBody(req)
|
|
766
|
-
// 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
|
|
767
|
-
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
768
808
|
const activeWork = collectActiveWork(ctx)
|
|
769
809
|
if (activeWork.total > 0 && requestBody.force !== true) {
|
|
770
810
|
audit('restart', 'rejected', 'reason=active-work total=' + activeWork.total)
|
|
@@ -775,12 +815,9 @@ export function apply(ctx) {
|
|
|
775
815
|
})
|
|
776
816
|
return
|
|
777
817
|
}
|
|
778
|
-
|
|
779
|
-
audit('restart', 'triggered', requestBody.force === true ? 'forced=true' : 'forced=false')
|
|
818
|
+
// 响应先发,退出调度经统一入口(与升级落定关机同一条链)
|
|
780
819
|
sendJson(res, 200, { ok: true, restarting: true })
|
|
781
|
-
|
|
782
|
-
// 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
|
|
783
|
-
setTimeout(() => exit(0), RESTART_DELAY_MS)
|
|
820
|
+
scheduleHostExit({ detail: 'forced=' + (requestBody.force === true), delayMs: RESTART_DELAY_MS })
|
|
784
821
|
}),
|
|
785
822
|
},
|
|
786
823
|
]
|
package/test/dist-tags.test.mjs
CHANGED
|
@@ -191,6 +191,42 @@ test('场景:全部途径失败返回 null 不抛错', async () => {
|
|
|
191
191
|
assert.equal(version, null)
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
+
test('场景:运行宿主候选(argv1 推导)优先于全局布局', async () => {
|
|
195
|
+
// 多副本共存:全局是 0.1.5,实际运行的是副本树内 0.1.1-rc.2,运行版本必须取 argv1 推导的包根
|
|
196
|
+
const files = {
|
|
197
|
+
'C:\\deploy\\.dsh-versions\\0.1.1-rc.2\\node_modules\\@deepseek-ai\\dsh\\package.json': JSON.stringify({ version: '0.1.1-rc.2' }),
|
|
198
|
+
'C:\\nvm\\v24\\node_modules\\@deepseek-ai\\dsh\\package.json': JSON.stringify({ version: '0.1.5-rc.1' }),
|
|
199
|
+
}
|
|
200
|
+
const version = await resolveHostVersion({
|
|
201
|
+
execPath: 'C:\\nvm\\v24\\node.exe',
|
|
202
|
+
platform: 'win32',
|
|
203
|
+
argv1: 'C:\\deploy\\.dsh-versions\\0.1.1-rc.2\\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js',
|
|
204
|
+
readFileImpl: async (path) => {
|
|
205
|
+
if (!Object.prototype.hasOwnProperty.call(files, path)) throw new Error('ENOENT')
|
|
206
|
+
return files[path]
|
|
207
|
+
},
|
|
208
|
+
resolveImpl: () => { throw new Error('MODULE_NOT_FOUND') },
|
|
209
|
+
})
|
|
210
|
+
assert.equal(version, '0.1.1-rc.2')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('场景:argv1 缺失或不可读时回退全局布局候选', async () => {
|
|
214
|
+
const files = {
|
|
215
|
+
'/prefix/lib/node_modules/@deepseek-ai/dsh/package.json': JSON.stringify({ version: '1.0.0' }),
|
|
216
|
+
}
|
|
217
|
+
const readFileFor = (files) => async (path) => {
|
|
218
|
+
if (!Object.prototype.hasOwnProperty.call(files, path)) throw new Error('ENOENT')
|
|
219
|
+
return files[path]
|
|
220
|
+
}
|
|
221
|
+
const common = { execPath: '/prefix/bin/node', platform: 'linux', resolveImpl: () => { throw new Error('MODULE_NOT_FOUND') } }
|
|
222
|
+
// argv1 为空
|
|
223
|
+
const v1 = await resolveHostVersion({ ...common, argv1: undefined, readFileImpl: readFileFor(files) })
|
|
224
|
+
assert.equal(v1, '1.0.0')
|
|
225
|
+
// argv1 指向的包根不可读,回退
|
|
226
|
+
const v2 = await resolveHostVersion({ ...common, argv1: '/nowhere/lib/bin.js', readFileImpl: readFileFor(files) })
|
|
227
|
+
assert.equal(v2, '1.0.0')
|
|
228
|
+
})
|
|
229
|
+
|
|
194
230
|
test('场景:候选文件可读但 version 非法时静默换下一候选', async () => {
|
|
195
231
|
const files = {
|
|
196
232
|
'/prefix/lib/node_modules/@deepseek-ai/dsh/package.json': JSON.stringify({ name: 'x' }),
|
package/test/parity.test.mjs
CHANGED
|
@@ -92,6 +92,17 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
|
|
|
92
92
|
assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
+
// host 侧锚点直接 import runtime.mjs 实现,防测试内手抄字面量漂移假绿
|
|
96
|
+
import { RUNTIME_KINDS } from '../src/runtime.mjs'
|
|
97
|
+
|
|
98
|
+
test('parity: 手动直跑运行环境常量 client 与 host 一致', () => {
|
|
99
|
+
assert.equal(extractConst('RUNTIME_KIND_MANUAL'), RUNTIME_KINDS.MANUAL_START)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('parity: 弹窗文案自动重启延迟秒与 host AUTO_RESTART_DELAY_MS 换算一致', () => {
|
|
103
|
+
assert.equal(extractNumberConst('AUTO_RESTART_DELAY_SEC'), AUTO_RESTART_DELAY_MS / 1000)
|
|
104
|
+
})
|
|
105
|
+
|
|
95
106
|
// host 侧锚点直接 import index.js 实现,防测试内手抄字面量漂移假绿
|
|
96
107
|
import {
|
|
97
108
|
DEFAULT_UPGRADE_TEMPLATE,
|
package/test/route.test.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { test } from 'node:test'
|
|
|
6
6
|
import assert from 'node:assert/strict'
|
|
7
7
|
import { EventEmitter } from 'node:events'
|
|
8
8
|
|
|
9
|
-
import { apply, RESTART_DELAY_MS, UPGRADE_LOCK_PATH, collectActiveWork } from '../src/index.js'
|
|
9
|
+
import { apply, RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS, UPGRADE_LOCK_PATH, collectActiveWork } from '../src/index.js'
|
|
10
10
|
import { rmSync, readFileSync } from 'node:fs'
|
|
11
11
|
|
|
12
12
|
// 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
|
|
@@ -128,6 +128,15 @@ async function call(routes, path, req) {
|
|
|
128
128
|
const post = (routes, path, body, headers) => call(routes, path, makeReq({ method: 'POST', body, headers }))
|
|
129
129
|
const get = (routes, path) => call(routes, path, makeReq({ method: 'GET' }))
|
|
130
130
|
|
|
131
|
+
// 排空微任务链(setImmediate 为宏任务,先于其执行的全部微任务此后必已完成)
|
|
132
|
+
const drainMicrotasks = () => new Promise((resolve) => setImmediate(resolve))
|
|
133
|
+
// 真实时间等待:mock timers 域内 setTimeout 不再真实计时,以 setImmediate 自旋让出事件循环
|
|
134
|
+
const realSleep = (ms) => new Promise((resolve) => {
|
|
135
|
+
const start = Date.now()
|
|
136
|
+
const spin = () => { if (Date.now() - start >= ms) resolve(); else setImmediate(spin) }
|
|
137
|
+
setImmediate(spin)
|
|
138
|
+
})
|
|
139
|
+
|
|
131
140
|
test('挂载:8 条路由注册,启动检查后快照就绪', async () => {
|
|
132
141
|
const { ctx, routes } = makeCtx()
|
|
133
142
|
apply(ctx)
|
|
@@ -302,7 +311,7 @@ test('upgrade:运行版本已是通道最新 409 拒绝(防降级),unknown 放
|
|
|
302
311
|
const refreshed = await post(routes, '/api/maintain/refresh')
|
|
303
312
|
assert.equal(refreshed.status, 200)
|
|
304
313
|
assert.equal(refreshed.payload.verdict, 'up-to-date', '前置:注入运行版本应高于 0.0.1 假目标')
|
|
305
|
-
const denied = await post(routes, '/api/maintain/upgrade')
|
|
314
|
+
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
306
315
|
assert.equal(denied.status, 409)
|
|
307
316
|
assert.match(denied.payload.error, /已是通道最新版/)
|
|
308
317
|
assert.equal(store.upgradeCommandTemplate, 'node -e "process.exit(0)"', '拒绝路径不得触发升级')
|
|
@@ -328,7 +337,7 @@ test('upgrade:verdict unknown 放行(tags 未就绪不得 409 误拒)', async ()
|
|
|
328
337
|
assert.ok(snapshot, '启动检查 5 秒内未完成')
|
|
329
338
|
assert.equal(snapshot.tags, null, '前置:registry 不可达,tags 未就绪')
|
|
330
339
|
assert.equal(snapshot.verdict, 'unknown', '前置:verdict 应为 unknown')
|
|
331
|
-
const allowed = await post(routes, '/api/maintain/upgrade')
|
|
340
|
+
const allowed = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
332
341
|
assert.equal(allowed.status, 200, 'verdict unknown 必须放行,不得 409 误拒')
|
|
333
342
|
assert.equal(allowed.payload.upgrade.running, true)
|
|
334
343
|
// 等假命令落定:防残留升级锁毒化后续用例
|
|
@@ -357,11 +366,11 @@ test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查',
|
|
|
357
366
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
358
367
|
apply(ctx)
|
|
359
368
|
const baseline = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
360
|
-
const first = await post(routes, '/api/maintain/upgrade')
|
|
369
|
+
const first = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
361
370
|
assert.equal(first.status, 200)
|
|
362
371
|
assert.equal(first.payload.upgrade.running, true)
|
|
363
372
|
assert.equal(first.payload.upgradeLockHeld, true, '升级期间锁文件持有效力')
|
|
364
|
-
const second = await post(routes, '/api/maintain/upgrade')
|
|
373
|
+
const second = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
365
374
|
assert.equal(second.status, 409)
|
|
366
375
|
// 等挂起命令自然退出(轮询而非固定 sleep,兼做落定状态显式断言)
|
|
367
376
|
let settled = null
|
|
@@ -377,47 +386,55 @@ test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查',
|
|
|
377
386
|
assert.ok(settled.checkedAt !== null)
|
|
378
387
|
})
|
|
379
388
|
|
|
380
|
-
test('upgrade
|
|
389
|
+
test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩子零网络零读盘', async (t) => {
|
|
381
390
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
382
391
|
const exits = []
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
389
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
390
|
-
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
391
|
-
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
392
|
+
let fetchCalls = 0
|
|
393
|
+
const originalFetch = globalThis.fetch
|
|
394
|
+
globalThis.fetch = async (...args) => {
|
|
395
|
+
fetchCalls += 1
|
|
396
|
+
return originalFetch(...args)
|
|
392
397
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
398
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
399
|
+
try {
|
|
400
|
+
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
401
|
+
apply(ctx)
|
|
402
|
+
// 等启动检查落定,固定网络请求基线(mock timers 域内以 realSleep 自旋让出事件循环)
|
|
403
|
+
let baseline = null
|
|
404
|
+
for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
|
|
405
|
+
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
406
|
+
if (poll && poll.checkedAt !== null) baseline = fetchCalls
|
|
407
|
+
else await realSleep(25)
|
|
408
|
+
}
|
|
409
|
+
assert.ok(baseline !== null, '启动检查 5 秒内未完成')
|
|
410
|
+
const first = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
411
|
+
assert.equal(first.status, 200)
|
|
412
|
+
let settled = null
|
|
413
|
+
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
414
|
+
await realSleep(30)
|
|
415
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
416
|
+
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
417
|
+
}
|
|
418
|
+
assert.ok(settled, '升级应在假命令退出后落定')
|
|
419
|
+
assert.equal(settled.upgrade.last.ok, true)
|
|
420
|
+
// 关机路径零复读:版本复读属升级后磁盘读取,是明确的故障源,砍掉
|
|
421
|
+
assert.equal(settled.upgrade.last.installedVersion, null, '关机路径禁止复读磁盘版本')
|
|
422
|
+
assert.equal(settled.upgrade.last.stale, null, '关机路径无 stale 判定')
|
|
423
|
+
assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
|
|
424
|
+
assert.equal(settled.autoRestartScheduled, true, '命令成功即调度关机(stale 不再抑制)')
|
|
425
|
+
assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
|
|
426
|
+
assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
|
|
427
|
+
// 落定钩子零网络:观察窗口内 fetch 计数不得增长(runCheck 已随关机路径移除)
|
|
428
|
+
await realSleep(300)
|
|
429
|
+
assert.equal(fetchCalls, baseline, '落定钩子禁止网络请求')
|
|
430
|
+
// 关机动作与确认重启同源:延迟窗口后 exit(0),窗口内不得提前
|
|
431
|
+
assert.deepEqual(exits, [], '延迟窗口内不得提前退出')
|
|
432
|
+
t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
|
|
433
|
+
assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
|
|
434
|
+
} finally {
|
|
435
|
+
globalThis.fetch = originalFetch
|
|
436
|
+
rmSync(UPGRADE_LOCK_PATH, { force: true })
|
|
417
437
|
}
|
|
418
|
-
// 观察窗口覆盖自动重启延迟:若误调度,延迟窗口内 exit 会被调用
|
|
419
|
-
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
420
|
-
assert.deepEqual(exits, [], 'stale 时禁止调度任何宿主退出')
|
|
421
438
|
})
|
|
422
439
|
|
|
423
440
|
test('restart:升级进行中 409 拒绝且不调度退出', async () => {
|
|
@@ -425,7 +442,7 @@ test('restart:升级进行中 409 拒绝且不调度退出', async () => {
|
|
|
425
442
|
const exits = []
|
|
426
443
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
427
444
|
apply(ctx)
|
|
428
|
-
const upgrade = await post(routes, '/api/maintain/upgrade')
|
|
445
|
+
const upgrade = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
429
446
|
assert.equal(upgrade.status, 200)
|
|
430
447
|
const denied = await post(routes, '/api/maintain/restart')
|
|
431
448
|
assert.equal(denied.status, 409)
|
|
@@ -447,13 +464,50 @@ test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
|
|
|
447
464
|
apply(ctx)
|
|
448
465
|
const restart = await post(routes, '/api/maintain/restart')
|
|
449
466
|
assert.equal(restart.status, 200)
|
|
450
|
-
const denied = await post(routes, '/api/maintain/upgrade')
|
|
467
|
+
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
451
468
|
assert.equal(denied.status, 409)
|
|
452
469
|
assert.match(denied.payload.error, /重启已调度/)
|
|
453
470
|
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
454
471
|
assert.deepEqual(exits, [0])
|
|
455
472
|
})
|
|
456
473
|
|
|
474
|
+
test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起检查', async (t) => {
|
|
475
|
+
// 关机延迟窗口内网络请求与磁盘读取都是半写状态风险:refresh 全拒,registry-base 只落盘
|
|
476
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
477
|
+
const store = { registryBase: 'https://registry.npmjs.org' }
|
|
478
|
+
let fetchCalls = 0
|
|
479
|
+
const originalFetch = globalThis.fetch
|
|
480
|
+
globalThis.fetch = async (...args) => {
|
|
481
|
+
fetchCalls += 1
|
|
482
|
+
return originalFetch(...args)
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const exits = []
|
|
486
|
+
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
487
|
+
apply(ctx)
|
|
488
|
+
let baseline = null
|
|
489
|
+
for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
|
|
490
|
+
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
491
|
+
if (poll && poll.checkedAt !== null) baseline = fetchCalls
|
|
492
|
+
else await realSleep(25)
|
|
493
|
+
}
|
|
494
|
+
assert.ok(baseline !== null, '启动检查 5 秒内未完成')
|
|
495
|
+
const restart = await post(routes, '/api/maintain/restart')
|
|
496
|
+
assert.equal(restart.status, 200)
|
|
497
|
+
const refresh = await post(routes, '/api/maintain/refresh')
|
|
498
|
+
assert.equal(refresh.status, 409, '关机窗口内检查更新必须拒绝')
|
|
499
|
+
const saved = await post(routes, '/api/maintain/registry-base', { base: 'https://mirror.example.org' })
|
|
500
|
+
assert.equal(saved.status, 200, '镜像地址保存照常落盘')
|
|
501
|
+
assert.equal(store.registryBase, 'https://mirror.example.org')
|
|
502
|
+
assert.equal(saved.payload.registryBase, 'https://mirror.example.org', 'status 回显新地址')
|
|
503
|
+
assert.equal(fetchCalls, baseline, '关机窗口内不得发起任何网络检查')
|
|
504
|
+
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
505
|
+
assert.deepEqual(exits, [0])
|
|
506
|
+
} finally {
|
|
507
|
+
globalThis.fetch = originalFetch
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
|
|
457
511
|
test('status:快照携带 bootAt 实例代际', async () => {
|
|
458
512
|
const { ctx, routes } = makeCtx()
|
|
459
513
|
apply(ctx)
|
|
@@ -495,6 +549,103 @@ test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
|
|
|
495
549
|
assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
|
|
496
550
|
})
|
|
497
551
|
|
|
552
|
+
test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale', async (t) => {
|
|
553
|
+
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
554
|
+
const exits = []
|
|
555
|
+
const { ctx, routes } = makeCtx({
|
|
556
|
+
settingsStore: store,
|
|
557
|
+
appExit: (code) => exits.push(code),
|
|
558
|
+
// 探针恒读旧版:模拟假命令不改磁盘,复读版本与触发前快照一致即 stale
|
|
559
|
+
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
560
|
+
})
|
|
561
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
562
|
+
apply(ctx)
|
|
563
|
+
// 排空启动期微任务链,探针读序此后稳定
|
|
564
|
+
await drainMicrotasks()
|
|
565
|
+
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
|
|
566
|
+
assert.equal(trigger.status, 200)
|
|
567
|
+
let settled = null
|
|
568
|
+
try {
|
|
569
|
+
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
570
|
+
await realSleep(30)
|
|
571
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
572
|
+
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
573
|
+
}
|
|
574
|
+
assert.ok(settled, '升级应在假命令退出后落定')
|
|
575
|
+
assert.equal(settled.upgrade.last.ok, true)
|
|
576
|
+
// 假命令不改磁盘,复读版本与触发前快照一致 → stale
|
|
577
|
+
assert.equal(settled.upgrade.last.stale, true, '继续运行路径必须复读并标注 stale')
|
|
578
|
+
assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0, 'stale 必须带原因')
|
|
579
|
+
assert.equal(settled.autoRestartScheduled, false, '未勾选不得置调度标记')
|
|
580
|
+
assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '托管环境未勾选不标手动指引')
|
|
581
|
+
// 推过完整调度延迟:若误调度,延迟窗口内 exit 必被调用
|
|
582
|
+
t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
|
|
583
|
+
assert.deepEqual(exits, [], '未勾选自动重启时禁止调度任何宿主退出')
|
|
584
|
+
} finally {
|
|
585
|
+
rmSync(UPGRADE_LOCK_PATH, { force: true })
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', async (t) => {
|
|
590
|
+
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
591
|
+
const exits = []
|
|
592
|
+
const { ctx, routes } = makeCtx({
|
|
593
|
+
settingsStore: store,
|
|
594
|
+
appExit: (code) => exits.push(code),
|
|
595
|
+
// 关机路径零读盘:探针返回值不影响断言,恒读旧版即可
|
|
596
|
+
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
597
|
+
})
|
|
598
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
599
|
+
apply(ctx)
|
|
600
|
+
await drainMicrotasks()
|
|
601
|
+
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
602
|
+
assert.equal(trigger.status, 200)
|
|
603
|
+
let settled = null
|
|
604
|
+
try {
|
|
605
|
+
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
606
|
+
await realSleep(30)
|
|
607
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
608
|
+
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
609
|
+
}
|
|
610
|
+
assert.ok(settled, '升级应在假命令退出后落定')
|
|
611
|
+
assert.equal(settled.upgrade.last.ok, true)
|
|
612
|
+
assert.equal(settled.upgrade.last.stale, null, '关机路径零复读,stale 不再判定')
|
|
613
|
+
// 落定可见与调度置位之间隔 runtimeEnvReady 的 await:await 一次 status 让微任务链走完再断言
|
|
614
|
+
const scheduled = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
615
|
+
assert.equal(scheduled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
|
|
616
|
+
assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
|
|
617
|
+
t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
|
|
618
|
+
assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
|
|
619
|
+
} finally {
|
|
620
|
+
rmSync(UPGRADE_LOCK_PATH, { force: true })
|
|
621
|
+
}
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('upgrade:autoRestart 缺失或非 boolean 一律 400', async () => {
|
|
625
|
+
// 假命令兜底:本用例期待 400,但若校验意外放行,真实默认模板会当场触发 npm install
|
|
626
|
+
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
627
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
628
|
+
apply(ctx)
|
|
629
|
+
const missing = await post(routes, '/api/maintain/upgrade')
|
|
630
|
+
assert.equal(missing.status, 400, '缺失 autoRestart 必须拒绝,防静默翻转关机行为')
|
|
631
|
+
const stringInput = await post(routes, '/api/maintain/upgrade', { autoRestart: 'true' })
|
|
632
|
+
assert.equal(stringInput.status, 400, '字符串宽转必须拒绝')
|
|
633
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
634
|
+
assert.equal(status.upgrade.running, false, '非法输入不得触发升级')
|
|
635
|
+
assert.equal(status.upgrade.last, null, '非法输入不得产生升级记录')
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async (t) => {
|
|
639
|
+
// 锁定 handler 顺序:readBody 在全部门控之前,门控到置位之间零 await
|
|
640
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
641
|
+
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
642
|
+
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: () => {} })
|
|
643
|
+
apply(ctx)
|
|
644
|
+
await post(routes, '/api/maintain/restart')
|
|
645
|
+
const denied = await post(routes, '/api/maintain/upgrade')
|
|
646
|
+
assert.equal(denied.status, 400, 'body 非法时即使门控(重启已调度)命中也必须 400')
|
|
647
|
+
})
|
|
648
|
+
|
|
498
649
|
test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
499
650
|
const store = {}
|
|
500
651
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
@@ -589,6 +740,33 @@ test('collectActiveWork:服务缺失或异常 fail-open 降级', () => {
|
|
|
589
740
|
assert.equal(realmOnly.detectionAvailable, true)
|
|
590
741
|
})
|
|
591
742
|
|
|
743
|
+
test('collectActiveWork:无 agent 时 terminals 服务面不可见属常态,不降级', () => {
|
|
744
|
+
const warns = []
|
|
745
|
+
const originalWarn = console.warn
|
|
746
|
+
console.warn = (text) => warns.push(String(text))
|
|
747
|
+
try {
|
|
748
|
+
const agents = { list: () => [] }
|
|
749
|
+
const jobs = { list: () => [] }
|
|
750
|
+
const result = collectActiveWork({ get: (n) => (n === 'agents' ? agents : n === 'jobs' ? jobs : undefined) })
|
|
751
|
+
assert.deepEqual([result.agents, result.jobs, result.terminals], [0, 0, 0])
|
|
752
|
+
assert.equal(result.detectionAvailable, true)
|
|
753
|
+
// 告警断言受模块级去重影响恒真,防线是上一行 detectionAvailable 断言
|
|
754
|
+
assert.equal(warns.some((text) => text.includes('活跃工作检测降级')), false, '常态空窗不得打降级告警')
|
|
755
|
+
} finally {
|
|
756
|
+
console.warn = originalWarn
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
test('collectActiveWork:有 agent 而服务面缺失(可探或不可探),仍降级', () => {
|
|
761
|
+
const probeless = { id: 'a1', status: 'idle' }
|
|
762
|
+
const blindProbe = { id: 'a2', status: 'idle', ctx: { get: () => undefined } }
|
|
763
|
+
const probelessResult = collectActiveWork({ get: (n) => (n === 'agents' ? { list: () => [probeless] } : n === 'jobs' ? { list: () => [] } : undefined) })
|
|
764
|
+
assert.equal(probelessResult.detectionAvailable, false)
|
|
765
|
+
const blindResult = collectActiveWork({ get: (n) => (n === 'agents' ? { list: () => [blindProbe] } : n === 'jobs' ? { list: () => [] } : undefined) })
|
|
766
|
+
assert.equal(blindResult.detectionAvailable, false)
|
|
767
|
+
assert.equal(blindResult.terminals, 0)
|
|
768
|
+
})
|
|
769
|
+
|
|
592
770
|
test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
|
|
593
771
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
594
772
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
@@ -596,7 +774,7 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
|
|
|
596
774
|
const terminals = { list: () => [] }
|
|
597
775
|
const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
|
|
598
776
|
apply(ctx)
|
|
599
|
-
const denied = await post(routes, '/api/maintain/upgrade')
|
|
777
|
+
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
600
778
|
assert.equal(denied.status, 409)
|
|
601
779
|
assert.match(denied.payload.error, /活跃工作/)
|
|
602
780
|
assert.equal(denied.payload.items.agents, 1)
|
|
@@ -649,7 +827,7 @@ test('自动重启接线:落定链消费运行环境并分流调度与手动指
|
|
|
649
827
|
assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
|
|
650
828
|
assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
|
|
651
829
|
assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
|
|
652
|
-
assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?
|
|
830
|
+
assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
|
|
653
831
|
})
|
|
654
832
|
|
|
655
833
|
test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
|
|
@@ -659,7 +837,7 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
|
|
|
659
837
|
const exits = []
|
|
660
838
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
661
839
|
apply(ctx)
|
|
662
|
-
await post(routes, '/api/maintain/upgrade')
|
|
840
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
663
841
|
let settled = null
|
|
664
842
|
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
665
843
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -670,8 +848,8 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
|
|
|
670
848
|
assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
|
|
671
849
|
assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
|
|
672
850
|
assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
|
|
673
|
-
// stale
|
|
674
|
-
assert.equal(settled.upgrade.last.requiresManualRestart,
|
|
851
|
+
// 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
|
|
852
|
+
assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
|
|
675
853
|
} finally {
|
|
676
854
|
delete process.env.DSH_MAINTAIN_RUNTIME_ENV
|
|
677
855
|
}
|
|
@@ -687,7 +865,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
687
865
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
688
866
|
const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
|
|
689
867
|
apply(ctx)
|
|
690
|
-
await post(routes, '/api/maintain/upgrade')
|
|
868
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
691
869
|
assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
|
|
692
870
|
await post(routes, '/api/maintain/restart')
|
|
693
871
|
assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
|
|
@@ -702,7 +880,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
702
880
|
console.warn = (text) => plainWarns.push(String(text))
|
|
703
881
|
try {
|
|
704
882
|
apply(plainCtx.ctx)
|
|
705
|
-
await post(plainCtx.routes, '/api/maintain/upgrade')
|
|
883
|
+
await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
706
884
|
for (let i = 0; i < 50; i += 1) {
|
|
707
885
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
708
886
|
const status = await get(plainCtx.routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
|
|
2
|
+
// 属无报错的 UI 损坏,故对源文本做静态断言锁死结构(usage-panel 同款守卫)
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
+
assert.match(source, /\.dm-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
|
|
17
|
+
const bare = source.match(/\.dm-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('switch 文字标签的视觉提前依赖 order 规则,删除即回退到 track 右侧', () => {
|
|
22
|
+
assert.match(source, /\.dm-switch \.dm-row__label \{ order:-1/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('checkbox 仅允许出现在 Switch 组件内,禁止裸 checkbox 直出', () => {
|
|
26
|
+
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
27
|
+
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
28
|
+
const factoryStart = source.indexOf('function Switch(')
|
|
29
|
+
const factoryEnd = source.indexOf('dm-switch__thumb', factoryStart)
|
|
30
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 Switch 组件内')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Switch 产出顺序为 input 在前 track 在后', () => {
|
|
34
|
+
const factory = source.slice(source.indexOf('function Switch('))
|
|
35
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('dm-switch__track'), '组件内 input 必须先于 track')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('Switch 的 input 与 track 必须紧邻(状态选择器为紧邻组合器,中间插元素即视觉失效)', () => {
|
|
39
|
+
const factory = source.slice(source.indexOf('function Switch('))
|
|
40
|
+
const inputAt = factory.indexOf("h('input'")
|
|
41
|
+
const trackAt = factory.indexOf("h('span', { className: 'dm-switch__track'")
|
|
42
|
+
assert.ok(inputAt >= 0 && trackAt > inputAt, '前置:input 先于 track')
|
|
43
|
+
const between = factory.slice(inputAt + 1, trackAt)
|
|
44
|
+
assert.equal((between.match(/\bh\('/g) || []).length, 0, 'input 与 track 之间不得插入其他元素')
|
|
45
|
+
})
|
package/test/upgrade.test.mjs
CHANGED
|
@@ -219,19 +219,29 @@ test('重试:假命令真实进程 文件锁失败后重试成功', async () =>
|
|
|
219
219
|
}
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
-
test('自动重启:四条件守卫(
|
|
223
|
-
// 成功+托管(含 unknown)→ 调度
|
|
224
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
225
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
226
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
222
|
+
test('自动重启:四条件守卫(成功/非手动直跑/enabled/appExit 可用)', () => {
|
|
223
|
+
// 成功+托管(含 unknown)+enabled=true → 调度
|
|
224
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
|
|
225
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
|
|
226
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
|
|
227
227
|
// 失败不调度
|
|
228
|
-
assert.deepEqual(judgeAutoRestart({ ok: false,
|
|
229
|
-
// stale 不调度
|
|
230
|
-
assert.deepEqual(judgeAutoRestart({ ok: true, stale: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: false, requiresManualRestart: false })
|
|
228
|
+
assert.deepEqual(judgeAutoRestart({ ok: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: false })
|
|
231
229
|
// 手动直跑 → 手动指引,不调度
|
|
232
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
230
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: true })
|
|
233
231
|
// appExit 缺失不调度
|
|
234
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
232
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: false })
|
|
235
233
|
// 手动直跑与 appExit 缺失并存 → 仍以手动指引标记(指引面板,与退出能力无关)
|
|
236
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
234
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: true })
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('自动重启:enabled 严格 boolean,仅 true 调度', () => {
|
|
238
|
+
// 显式 false:托管(含 unknown)不调度、不标手动指引
|
|
239
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
|
|
240
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
|
|
241
|
+
// 手动直跑指引是环境约束事实,不受勾选影响
|
|
242
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: true })
|
|
243
|
+
// 非 true 一律不调度:调用点经升级路由严格 boolean 校验,此处锁定纯函数自身契约
|
|
244
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: undefined }), { schedule: false, requiresManualRestart: false })
|
|
245
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: null }), { schedule: false, requiresManualRestart: false })
|
|
246
|
+
assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: 'true' }), { schedule: false, requiresManualRestart: false })
|
|
237
247
|
})
|