@mzzsfy/dsh-maintain 0.6.1 → 0.7.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 +4 -4
- package/package.json +1 -1
- package/src/client.js +95 -15
- package/src/core.mjs +16 -4
- package/src/index.js +76 -41
- package/test/dist-tags.test.mjs +36 -0
- package/test/parity.test.mjs +11 -0
- package/test/route.test.mjs +202 -51
- package/test/switch-guard.test.mjs +45 -0
- package/test/upgrade.test.mjs +21 -11
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ 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 可强制指定,最高优先)
|
|
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
13
|
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
|
|
14
14
|
- 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
|
|
15
15
|
- 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
|
|
@@ -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)
|
|
@@ -363,6 +390,21 @@ function EditRow(props) {
|
|
|
363
390
|
)
|
|
364
391
|
}
|
|
365
392
|
|
|
393
|
+
// 布尔开关:原生 checkbox 保可访问性,视觉为 track 胶囊 + thumb 圆点(规约形态);
|
|
394
|
+
// input 与 track 必须保持紧邻兄弟(状态选择器为紧邻组合器),文字标签排最后,视觉经 order 提前
|
|
395
|
+
function Switch(props) {
|
|
396
|
+
return h('label', { className: 'dm-switch', title: props.title },
|
|
397
|
+
h('input', {
|
|
398
|
+
type: 'checkbox',
|
|
399
|
+
checked: props.checked === true,
|
|
400
|
+
disabled: props.disabled === true,
|
|
401
|
+
onChange: (e) => props.onChange(e.target.checked),
|
|
402
|
+
}),
|
|
403
|
+
h('span', { className: 'dm-switch__track' }, h('span', { className: 'dm-switch__thumb' })),
|
|
404
|
+
props.label ? h('span', { className: 'dm-row__label' }, props.label) : null,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
366
408
|
// 版本区:当前版本、通道切换、通道最新版、结论、检查时间与错误、刷新与升级、升级命令编辑。
|
|
367
409
|
function VersionCard(props) {
|
|
368
410
|
const status = props.status
|
|
@@ -389,7 +431,7 @@ function VersionCard(props) {
|
|
|
389
431
|
|| status.verdict === VERDICT_UP_TO_DATE,
|
|
390
432
|
title: status.verdict === VERDICT_UP_TO_DATE ? '当前已是通道最新版,无需升级;重装请走命令行' : undefined,
|
|
391
433
|
onClick: props.onUpgrade,
|
|
392
|
-
},
|
|
434
|
+
}, '升级'),
|
|
393
435
|
),
|
|
394
436
|
status.restartPending === true
|
|
395
437
|
? h('div', { className: 'dm-notice dm-notice--warn' },
|
|
@@ -522,11 +564,51 @@ function OpsCard(props) {
|
|
|
522
564
|
)
|
|
523
565
|
}
|
|
524
566
|
|
|
567
|
+
// 升级确认弹窗:展示将执行的命令({tag} 已替换为追踪通道)与自动重启勾选,确认才触发。
|
|
568
|
+
// 自动重启是会话级选择(不持久化),勾/不勾的后果在窗内明示:
|
|
569
|
+
// 勾选=命令成功后宿主延迟 3 秒退出,由进程管理器拉起新版本,页面随宿主恢复自动刷新;
|
|
570
|
+
// 不勾选=宿主继续运行旧版本,面板提示"重启宿主后生效",需手动点重启按钮
|
|
571
|
+
function UpgradeDialog(props) {
|
|
572
|
+
// 每次打开都重新挂载(MaintainApp 条件渲染),默认勾选随挂载重置
|
|
573
|
+
const [autoRestart, setAutoRestart] = useState(true)
|
|
574
|
+
const status = props.status
|
|
575
|
+
const template = typeof status.upgradeTemplate === 'string' && status.upgradeTemplate.length > 0
|
|
576
|
+
? status.upgradeTemplate
|
|
577
|
+
: DEFAULT_UPGRADE_TEMPLATE
|
|
578
|
+
const command = template.split('{tag}').join(status.channel)
|
|
579
|
+
return h('div', { className: 'dm-dialog__mask', onClick: props.onCancel },
|
|
580
|
+
h('div', { className: 'dm-dialog', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
581
|
+
h('div', { className: 'dm-card__title' }, '确认升级'),
|
|
582
|
+
h('div', { className: 'dm-dialog__hint' }, '将执行以下命令(升级期间页面可离开,进度由右下角浮条提示):'),
|
|
583
|
+
h('pre', { className: 'dm-pre' }, command),
|
|
584
|
+
h('div', { className: 'dm-dialog__section' },
|
|
585
|
+
h(Switch, {
|
|
586
|
+
label: '升级完成后自动重启宿主',
|
|
587
|
+
checked: autoRestart,
|
|
588
|
+
onChange: setAutoRestart,
|
|
589
|
+
}),
|
|
590
|
+
h('div', { className: 'dm-dialog__outcome' },
|
|
591
|
+
autoRestart
|
|
592
|
+
? '已勾选:升级命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起新版本,本页随宿主恢复自动刷新。升级落定后宿主不再执行任何网络请求与磁盘读取。'
|
|
593
|
+
: '未勾选:升级完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到新版本。'),
|
|
594
|
+
status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
|
|
595
|
+
? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
|
|
596
|
+
: null,
|
|
597
|
+
),
|
|
598
|
+
h('div', { className: 'dm-row' },
|
|
599
|
+
h('span', { className: 'dm-spacer' }),
|
|
600
|
+
h('button', { className: 'dm-btn', onClick: props.onCancel }, '取消'),
|
|
601
|
+
h('button', { className: 'dm-btn', onClick: () => props.onConfirm(autoRestart) }, '开始升级'),
|
|
602
|
+
),
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
525
607
|
function MaintainApp() {
|
|
526
608
|
const [status, setStatus] = useState(null)
|
|
527
609
|
const [error, setError] = useState(null)
|
|
528
610
|
const [busy, setBusy] = useState({})
|
|
529
|
-
const [
|
|
611
|
+
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false)
|
|
530
612
|
const [restartArmed, setRestartArmed] = useState(false)
|
|
531
613
|
const [restarting, setRestarting] = useState(false)
|
|
532
614
|
// 重启探测的跨拍状态快照 {lost,pid,bootAt,readyStreak}:lost 与 readyStreak 随拍更新,
|
|
@@ -742,15 +824,10 @@ function MaintainApp() {
|
|
|
742
824
|
submitEdit(REGISTRY_BASE_URL, { base }, 'registryBase', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
|
|
743
825
|
}
|
|
744
826
|
|
|
745
|
-
function onUpgrade() {
|
|
746
|
-
|
|
747
|
-
setUpgradeArmed(true)
|
|
748
|
-
setRestartArmed(false)
|
|
749
|
-
return
|
|
750
|
-
}
|
|
751
|
-
setUpgradeArmed(false)
|
|
827
|
+
function onUpgrade(autoRestart) {
|
|
828
|
+
setUpgradeDialogOpen(false)
|
|
752
829
|
markBusy('upgrade', true)
|
|
753
|
-
post(UPGRADE_URL)
|
|
830
|
+
post(UPGRADE_URL, { autoRestart: autoRestart === true })
|
|
754
831
|
.then((next) => {
|
|
755
832
|
setStatus(next); setError(null)
|
|
756
833
|
// 升级转后台执行:观察器接管状态跟踪与完成提示,页面可离开
|
|
@@ -764,7 +841,6 @@ function MaintainApp() {
|
|
|
764
841
|
if (restartPendingRef.current) return
|
|
765
842
|
if (!restartArmed) {
|
|
766
843
|
setRestartArmed(true)
|
|
767
|
-
setUpgradeArmed(false)
|
|
768
844
|
return
|
|
769
845
|
}
|
|
770
846
|
setRestartArmed(false)
|
|
@@ -826,13 +902,13 @@ function MaintainApp() {
|
|
|
826
902
|
error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
|
|
827
903
|
}
|
|
828
904
|
|
|
829
|
-
//
|
|
905
|
+
// 升级入口不可用即关闭确认弹窗:渲染时派生,不引入 effect。
|
|
830
906
|
// 不可用与 VersionCard 按钮 disabled 条件同源(升级进行中/残留锁/已最新/重启中)
|
|
831
907
|
const upgradeUnavailable = restarting
|
|
832
908
|
|| (status !== null && ((status.upgrade && status.upgrade.running === true)
|
|
833
909
|
|| status.upgradeLockHeld === true
|
|
834
910
|
|| status.verdict === VERDICT_UP_TO_DATE))
|
|
835
|
-
const
|
|
911
|
+
const upgradeDialogOpenLive = upgradeDialogOpen && !upgradeUnavailable
|
|
836
912
|
// 活跃工作计数:重启确认态显示计数并发 force 越过门控
|
|
837
913
|
const activeWorkTotal = status !== null && status.activeWork ? status.activeWork.total : 0
|
|
838
914
|
|
|
@@ -850,13 +926,12 @@ function MaintainApp() {
|
|
|
850
926
|
h(VersionCard, {
|
|
851
927
|
status,
|
|
852
928
|
busy,
|
|
853
|
-
upgradeArmed: upgradeArmedLive,
|
|
854
929
|
restartArmed,
|
|
855
930
|
restarting,
|
|
856
931
|
onRefresh,
|
|
857
932
|
onChannel,
|
|
858
933
|
onTemplateSave,
|
|
859
|
-
onUpgrade,
|
|
934
|
+
onUpgrade: () => { setRestartArmed(false); setUpgradeDialogOpen(true) },
|
|
860
935
|
onRestart,
|
|
861
936
|
}),
|
|
862
937
|
h(SettingsCard, {
|
|
@@ -868,6 +943,11 @@ function MaintainApp() {
|
|
|
868
943
|
}),
|
|
869
944
|
h(UpgradeCard, { status, restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
870
945
|
h(OpsCard, { status, armed: restartArmed, restarting, activeWorkTotal, onRestart }),
|
|
946
|
+
upgradeDialogOpenLive ? h(UpgradeDialog, {
|
|
947
|
+
status,
|
|
948
|
+
onCancel: () => setUpgradeDialogOpen(false),
|
|
949
|
+
onConfirm: onUpgrade,
|
|
950
|
+
}) : null,
|
|
871
951
|
)
|
|
872
952
|
}
|
|
873
953
|
|
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 提示文案对拍
|
|
@@ -202,12 +202,14 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
|
|
|
202
202
|
return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
//
|
|
206
|
-
// 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart
|
|
207
|
-
|
|
205
|
+
// 升级后自动重启守卫:四条件缺一不可——成功、非手动直跑、本次勾选 enabled、appExit 可用。
|
|
206
|
+
// 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引,
|
|
207
|
+
// 该指引是环境约束事实,不受勾选影响;enabled 由升级路由严格 boolean 校验保证
|
|
208
|
+
// 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
|
|
209
|
+
export function judgeAutoRestart({ ok, runtimeKind, hasExit, enabled }) {
|
|
208
210
|
if (ok !== true) return { schedule: false, requiresManualRestart: false }
|
|
209
|
-
if (stale === true) return { schedule: false, requiresManualRestart: false }
|
|
210
211
|
if (runtimeKind === RUNTIME_KINDS.MANUAL_START) return { schedule: false, requiresManualRestart: true }
|
|
212
|
+
if (enabled !== true) return { schedule: false, requiresManualRestart: false }
|
|
211
213
|
if (hasExit !== true) return { schedule: false, requiresManualRestart: false }
|
|
212
214
|
return { schedule: true, requiresManualRestart: false }
|
|
213
215
|
}
|
|
@@ -361,6 +363,7 @@ export function apply(ctx) {
|
|
|
361
363
|
return resolveHostVersion({
|
|
362
364
|
execPath: process.execPath,
|
|
363
365
|
platform: process.platform,
|
|
366
|
+
argv1: process.argv[1],
|
|
364
367
|
readFileImpl: readFile,
|
|
365
368
|
resolveImpl: undefined,
|
|
366
369
|
})
|
|
@@ -434,7 +437,8 @@ export function apply(ctx) {
|
|
|
434
437
|
return
|
|
435
438
|
}
|
|
436
439
|
const dispose = timerCtx.interval(() => {
|
|
437
|
-
|
|
440
|
+
// restartScheduled 窗口内不再发起检查:关机延迟期间的网络请求与磁盘读取都是半写状态风险
|
|
441
|
+
if (restartScheduled || checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
|
|
438
442
|
runCheck().then(scheduleNext, scheduleNext)
|
|
439
443
|
}, TICK_MS)
|
|
440
444
|
ctx.effect(() => dispose, 'dsh-maintain poll interval')
|
|
@@ -480,7 +484,7 @@ export function apply(ctx) {
|
|
|
480
484
|
}
|
|
481
485
|
}
|
|
482
486
|
|
|
483
|
-
function triggerUpgrade() {
|
|
487
|
+
function triggerUpgrade(autoRestart) {
|
|
484
488
|
const config = readSettings(ctx)
|
|
485
489
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
486
490
|
const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
|
|
@@ -505,8 +509,8 @@ export function apply(ctx) {
|
|
|
505
509
|
// running 即串行化门闩:路由检查与本处置位之间无 await,单线程下无竞态窗口
|
|
506
510
|
upgrade = { running: true, last }
|
|
507
511
|
writeUpgradeLock(last.startedAt)
|
|
508
|
-
audit('upgrade', 'triggered', 'command=' + singleLine(command))
|
|
509
|
-
void performUpgrade(command, last, config.channel)
|
|
512
|
+
audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
|
|
513
|
+
void performUpgrade(command, last, config.channel, autoRestart === true)
|
|
510
514
|
}
|
|
511
515
|
|
|
512
516
|
// 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
|
|
@@ -517,20 +521,24 @@ export function apply(ctx) {
|
|
|
517
521
|
} catch { /* 锁不可写仅损失跨进程防护,内存门闩仍生效 */ }
|
|
518
522
|
}
|
|
519
523
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
function
|
|
524
|
+
// 宿主退出调度唯一入口:手动重启(HTTP 入口)与升级落定(内部入口)共用,
|
|
525
|
+
// 置双向互斥标记、留审计、延迟 exit(0)。调度窗口内升级被 409,手动重启幂等
|
|
526
|
+
function scheduleHostExit({ detail, autoRestart = false, delayMs }) {
|
|
523
527
|
restartScheduled = true
|
|
524
|
-
autoRestartScheduled = true
|
|
525
|
-
audit('restart', 'scheduled', '
|
|
526
|
-
|
|
528
|
+
if (autoRestart === true) autoRestartScheduled = true
|
|
529
|
+
audit('restart', autoRestart === true ? 'scheduled' : 'triggered', detail + ' delayMs=' + delayMs)
|
|
530
|
+
// 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
|
|
531
|
+
// 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
|
|
532
|
+
setTimeout(() => exit(0), delayMs)
|
|
527
533
|
}
|
|
528
534
|
|
|
529
|
-
async function performUpgrade(command, last, channel) {
|
|
535
|
+
async function performUpgrade(command, last, channel, autoRestart) {
|
|
536
|
+
// settle 声明在 try 外:继续运行路径的版本复读在 finally 中消费它
|
|
537
|
+
let settle = null
|
|
530
538
|
try {
|
|
531
|
-
//
|
|
539
|
+
// 触发前快照磁盘版本,继续运行路径复读时以此为对比基线
|
|
532
540
|
last.previousVersion = await resolveCurrentHostVersion()
|
|
533
|
-
|
|
541
|
+
settle = await runUpgradeWithRetry({
|
|
534
542
|
command,
|
|
535
543
|
onAttemptStart: () => writeUpgradeLock(Date.now()),
|
|
536
544
|
})
|
|
@@ -547,20 +555,11 @@ export function apply(ctx) {
|
|
|
547
555
|
stdoutTail: settle.stdoutTail,
|
|
548
556
|
stderrTail: settle.stderrTail,
|
|
549
557
|
})
|
|
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
558
|
} catch (error) {
|
|
562
559
|
last.error = error && error.message ? error.message : String(error)
|
|
563
560
|
} finally {
|
|
561
|
+
// 关机路径经 return 提前离场会吞掉 finally 内新抛异常:本块各消费点已 try/catch
|
|
562
|
+
// 防御(删锁/复读/runCheck),后续修改必须保持该契约,勿在此块新增可抛调用
|
|
564
563
|
if (last.finishedAt === null) last.finishedAt = Date.now()
|
|
565
564
|
upgrade = { running: false, last }
|
|
566
565
|
// stillRunning(强杀后进程树疑似仍在写全局目录)时保留锁文件,由过期机制收敛,
|
|
@@ -580,14 +579,33 @@ export function apply(ctx) {
|
|
|
580
579
|
+ (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
|
|
581
580
|
audit('upgrade', last.ok === true ? 'ok' : 'failed',
|
|
582
581
|
'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
|
|
583
|
-
//
|
|
582
|
+
// 关机前置判定只取内存输入(启动期缓存的运行环境 + 本次会话勾选):升级命令刚
|
|
583
|
+
// 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
|
|
584
584
|
await runtimeEnvReady
|
|
585
|
-
const decision = judgeAutoRestart({ ok: last.ok === true,
|
|
585
|
+
const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
586
586
|
if (decision.requiresManualRestart === true) last.requiresManualRestart = true
|
|
587
587
|
if (decision.schedule === true) {
|
|
588
|
+
// 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
|
|
588
589
|
// last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
|
|
589
590
|
last.autoRestartScheduled = true
|
|
590
|
-
|
|
591
|
+
scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
// 继续运行路径(失败/手动直跑/本次未勾选自动重启):复读磁盘版本标 stale,版本未前进提示保留
|
|
595
|
+
if (settle !== null && settle.ok) {
|
|
596
|
+
// 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
|
|
597
|
+
// 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
|
|
598
|
+
try {
|
|
599
|
+
last.installedVersion = await resolveCurrentHostVersion()
|
|
600
|
+
const tags = snapshot.tags
|
|
601
|
+
const freshness = judgeUpgradeFreshness({
|
|
602
|
+
previousVersion: last.previousVersion,
|
|
603
|
+
installedVersion: last.installedVersion,
|
|
604
|
+
channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
|
|
605
|
+
})
|
|
606
|
+
last.stale = freshness.stale
|
|
607
|
+
last.reason = freshness.reason
|
|
608
|
+
} catch { /* 复读失败仅损失 stale 提示,不覆盖升级成功结果 */ }
|
|
591
609
|
}
|
|
592
610
|
// 升级结束后自动重新检查版本并重排轮询(命令可能改了本地版本)
|
|
593
611
|
runCheck().then(scheduleNext, scheduleNext)
|
|
@@ -606,6 +624,10 @@ export function apply(ctx) {
|
|
|
606
624
|
{
|
|
607
625
|
path: API_PATHS.REFRESH,
|
|
608
626
|
handler: route('POST', WRITE, async (req, res) => {
|
|
627
|
+
if (restartScheduled) {
|
|
628
|
+
sendJson(res, 409, { error: '重启已调度,禁止检查更新' })
|
|
629
|
+
return
|
|
630
|
+
}
|
|
609
631
|
await drainInFlightThenCheck()
|
|
610
632
|
scheduleNext()
|
|
611
633
|
sendJson(res, 200, await currentStatus())
|
|
@@ -698,6 +720,11 @@ export function apply(ctx) {
|
|
|
698
720
|
return
|
|
699
721
|
}
|
|
700
722
|
await settings.update(NAMESPACE, { registryBase: base })
|
|
723
|
+
if (restartScheduled) {
|
|
724
|
+
// 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
|
|
725
|
+
sendJson(res, 200, await currentStatus())
|
|
726
|
+
return
|
|
727
|
+
}
|
|
701
728
|
await drainInFlightThenCheck()
|
|
702
729
|
scheduleNext()
|
|
703
730
|
sendJson(res, 200, await currentStatus())
|
|
@@ -706,6 +733,15 @@ export function apply(ctx) {
|
|
|
706
733
|
{
|
|
707
734
|
path: API_PATHS.UPGRADE,
|
|
708
735
|
handler: route('POST', WRITE, async (req, res) => {
|
|
736
|
+
// body 校验必须在全部门控之前:门控到 triggerUpgrade 置位之间不得插入 await,
|
|
737
|
+
// 否则并发请求双双通过 409 门控,两个升级进程并发写全局目录
|
|
738
|
+
// 自动重启为确认弹窗的会话级勾选,严格 boolean:缺失/宽转一律拒绝,防静默翻转关机行为
|
|
739
|
+
const rawBody = await readBody(req)
|
|
740
|
+
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
741
|
+
if (typeof requestBody.autoRestart !== 'boolean') {
|
|
742
|
+
sendJson(res, 400, { error: 'autoRestart 必须是 boolean' })
|
|
743
|
+
return
|
|
744
|
+
}
|
|
709
745
|
// 与重启调度双向互斥:restartScheduled 置位到 exit(0) 执行的窗口内触发升级,
|
|
710
746
|
// detached 孤儿会与新宿主交错
|
|
711
747
|
if (upgrade.running || restartScheduled) {
|
|
@@ -737,13 +773,18 @@ export function apply(ctx) {
|
|
|
737
773
|
})
|
|
738
774
|
return
|
|
739
775
|
}
|
|
740
|
-
triggerUpgrade()
|
|
776
|
+
triggerUpgrade(requestBody.autoRestart)
|
|
741
777
|
sendJson(res, 200, await currentStatus())
|
|
742
778
|
}),
|
|
743
779
|
},
|
|
744
780
|
{
|
|
745
781
|
path: API_PATHS.RESTART,
|
|
746
782
|
handler: route('POST', WRITE, async (req, res) => {
|
|
783
|
+
// body 读取必须在全部门控之前:幂等检查到 scheduleHostExit 置位之间不得插入
|
|
784
|
+
// await,否则并发请求双双越过幂等门,双重调度 exit
|
|
785
|
+
const rawBody = await readBody(req)
|
|
786
|
+
// 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
|
|
787
|
+
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
747
788
|
// 与升级门闩互斥:升级子进程经 detached+unref 存活于宿主死后,
|
|
748
789
|
// 重启后新宿主门闩归零会放行第二次升级,双 npm install 并发写全局目录
|
|
749
790
|
if (upgrade.running) {
|
|
@@ -762,9 +803,6 @@ export function apply(ctx) {
|
|
|
762
803
|
}
|
|
763
804
|
// 活跃工作门控:重启可被 force 越过(升级后自动重启链路无法等待人工确认,
|
|
764
805
|
// 对齐 dsh-service:升级不可越/重启 force 可越)
|
|
765
|
-
const rawBody = await readBody(req)
|
|
766
|
-
// 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
|
|
767
|
-
const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
|
|
768
806
|
const activeWork = collectActiveWork(ctx)
|
|
769
807
|
if (activeWork.total > 0 && requestBody.force !== true) {
|
|
770
808
|
audit('restart', 'rejected', 'reason=active-work total=' + activeWork.total)
|
|
@@ -775,12 +813,9 @@ export function apply(ctx) {
|
|
|
775
813
|
})
|
|
776
814
|
return
|
|
777
815
|
}
|
|
778
|
-
|
|
779
|
-
audit('restart', 'triggered', requestBody.force === true ? 'forced=true' : 'forced=false')
|
|
816
|
+
// 响应先发,退出调度经统一入口(与升级落定关机同一条链)
|
|
780
817
|
sendJson(res, 200, { ok: true, restarting: true })
|
|
781
|
-
|
|
782
|
-
// 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
|
|
783
|
-
setTimeout(() => exit(0), RESTART_DELAY_MS)
|
|
818
|
+
scheduleHostExit({ detail: 'forced=' + (requestBody.force === true), delayMs: RESTART_DELAY_MS })
|
|
784
819
|
}),
|
|
785
820
|
},
|
|
786
821
|
]
|
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 })
|
|
@@ -596,7 +747,7 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
|
|
|
596
747
|
const terminals = { list: () => [] }
|
|
597
748
|
const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
|
|
598
749
|
apply(ctx)
|
|
599
|
-
const denied = await post(routes, '/api/maintain/upgrade')
|
|
750
|
+
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
600
751
|
assert.equal(denied.status, 409)
|
|
601
752
|
assert.match(denied.payload.error, /活跃工作/)
|
|
602
753
|
assert.equal(denied.payload.items.agents, 1)
|
|
@@ -649,7 +800,7 @@ test('自动重启接线:落定链消费运行环境并分流调度与手动指
|
|
|
649
800
|
assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
|
|
650
801
|
assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
|
|
651
802
|
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]*?
|
|
803
|
+
assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
|
|
653
804
|
})
|
|
654
805
|
|
|
655
806
|
test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
|
|
@@ -659,7 +810,7 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
|
|
|
659
810
|
const exits = []
|
|
660
811
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
661
812
|
apply(ctx)
|
|
662
|
-
await post(routes, '/api/maintain/upgrade')
|
|
813
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
663
814
|
let settled = null
|
|
664
815
|
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
665
816
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -670,8 +821,8 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
|
|
|
670
821
|
assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
|
|
671
822
|
assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
|
|
672
823
|
assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
|
|
673
|
-
// stale
|
|
674
|
-
assert.equal(settled.upgrade.last.requiresManualRestart,
|
|
824
|
+
// 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
|
|
825
|
+
assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
|
|
675
826
|
} finally {
|
|
676
827
|
delete process.env.DSH_MAINTAIN_RUNTIME_ENV
|
|
677
828
|
}
|
|
@@ -687,7 +838,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
687
838
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
688
839
|
const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
|
|
689
840
|
apply(ctx)
|
|
690
|
-
await post(routes, '/api/maintain/upgrade')
|
|
841
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
691
842
|
assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
|
|
692
843
|
await post(routes, '/api/maintain/restart')
|
|
693
844
|
assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
|
|
@@ -702,7 +853,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
702
853
|
console.warn = (text) => plainWarns.push(String(text))
|
|
703
854
|
try {
|
|
704
855
|
apply(plainCtx.ctx)
|
|
705
|
-
await post(plainCtx.routes, '/api/maintain/upgrade')
|
|
856
|
+
await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
706
857
|
for (let i = 0; i < 50; i += 1) {
|
|
707
858
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
708
859
|
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
|
})
|