@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 CHANGED
@@ -6,10 +6,10 @@ DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
6
6
 
7
7
  - 版本监测:host 启动即检查一次,按轮询间隔重复检查(内存快照,不持久化);当前版本与追踪通道最新版对比,落后即提示。运行版本(宿主启动时缓存)与已装版本(磁盘实时)分开统计与展示,落后判定以运行版本为准,已装版本领先运行版本时提示"重启后生效"。定时轮询为软依赖:宿主 timer 服务不可用时仅停用自动轮询,面板显示降级提示,手动检查与升级能力不受影响。
8
8
  - 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
9
- - 一键升级:两段式确认后按自定义命令模板执行,`{tag}` 执行时替换为追踪通道;命令可在设置面板直接查看与编辑,升级期间串行化(含跨进程锁文件:宿主被外部重启后,残留升级子进程的锁在有效期内仍拒绝新升级),单次尝试超时 10 分钟强杀;升级后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面即可重启生效;升级完成后自动重新检查版本。
9
+ - 一键升级:点击升级弹出确认窗,窗内展示将执行的实际命令(`{tag}` 已替换为追踪通道)与"升级完成后自动重启宿主"勾选项(默认勾选,本次升级会话级生效,不持久化),确认后才触发;命令模板可在设置面板直接查看与编辑。升级期间串行化(含跨进程锁文件:宿主被外部重启后,残留升级子进程的锁在有效期内仍拒绝新升级),单次尝试超时 10 分钟强杀;升级后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面即可重启生效;继续运行路径下升级完成后自动重新检查版本。
10
10
  - 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
11
- - 升级结果校验:升级成功落定后复读磁盘版本对比——版本未前进(镜像滞后或静默失败)或未达通道目标版本时,面板与浮条标注 stale 提示,不误导重启;运行版本已是通道最新时升级入口直接拒绝(409 防降级,面板按钮同步禁用),重装场景走命令行。超时强杀意味着安装可能只完成一半,重启宿主前先确认命令需否重跑。
12
- - 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级成功且复读校验通过时,托管环境下延迟 3 秒自动重启宿主(复用手动重启互斥,调度窗口内升级被拒),浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启;检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或 stale 不调度。
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-maintain",
3
3
  "description": "DSH 自身的版本管理:追踪 npm dist-tag 新版本、一键升级、安全重启",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
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
- }, props.upgradeArmed ? '确认升级' : '升级'),
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 [upgradeArmed, setUpgradeArmed] = useState(false)
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
- if (!upgradeArmed) {
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
- // 升级入口不可用即解除两段式待发:渲染时派生,不引入 effect。
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 upgradeArmedLive = upgradeArmed && !upgradeUnavailable
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
- // 宿主实际安装版本:先按全局布局候选读文件,再退 createRequire 解析(插件可能随宿主树部署)。
255
- // 全部失败返回 null,面板显示未知,不影响宿主。
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 ? candidates.concat(requireResolved) : candidates) {
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
- // 升级成功后自动重启的延迟:给 runCheck 与浮条终态一拍时间,观察器轮询可赶上;
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
- // 升级后自动重启守卫:四条件缺一不可——成功、非 stale、非手动直跑、appExit 可用。
206
- // 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引
207
- export function judgeAutoRestart({ ok, stale, runtimeKind, hasExit }) {
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
- if (checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
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
- // 自动重启与手动重启共用 restartScheduled 互斥:调度窗口内到达的升级被 409,
521
- // 手动重启端点幂等;autoRestartScheduled 单独给 status/client 分流终态文案
522
- function scheduleAutoRestart() {
524
+ // 宿主退出调度唯一入口:手动重启(HTTP 入口)与升级落定(内部入口)共用,
525
+ // 置双向互斥标记、留审计、延迟 exit(0)。调度窗口内升级被 409,手动重启幂等
526
+ function scheduleHostExit({ detail, autoRestart = false, delayMs }) {
523
527
  restartScheduled = true
524
- autoRestartScheduled = true
525
- audit('restart', 'scheduled', 'reason=upgrade-ok delayMs=' + AUTO_RESTART_DELAY_MS)
526
- setTimeout(() => exit(0), AUTO_RESTART_DELAY_MS)
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
- // 触发前快照磁盘版本,成功落定后复读对比,版本未前进或未达目标即标 stale
539
+ // 触发前快照磁盘版本,继续运行路径复读时以此为对比基线
532
540
  last.previousVersion = await resolveCurrentHostVersion()
533
- const settle = await runUpgradeWithRetry({
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, stale: last.stale === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function' })
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
- scheduleAutoRestart()
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
- restartScheduled = true
779
- audit('restart', 'triggered', requestBody.force === true ? 'forced=true' : 'forced=false')
816
+ // 响应先发,退出调度经统一入口(与升级落定关机同一条链)
780
817
  sendJson(res, 200, { ok: true, restarting: true })
781
- // 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
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
  ]
@@ -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' }),
@@ -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,
@@ -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:落定后复读磁盘版本,假命令未升级版本时标 stale', async () => {
389
+ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩子零网络零读盘', async (t) => {
381
390
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
382
391
  const exits = []
383
- const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
384
- apply(ctx)
385
- const first = await post(routes, '/api/maintain/upgrade')
386
- assert.equal(first.status, 200)
387
- let settled = null
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
- assert.ok(settled, '升级应在假命令退出后落定')
394
- assert.equal(settled.upgrade.last.ok, true)
395
- assert.ok('previousVersion' in settled.upgrade.last, '触发前磁盘版本快照必须进 status')
396
- assert.equal(settled.upgrade.last.installedVersion, settled.upgrade.last.previousVersion, '假命令不改磁盘,复读版本应与快照一致')
397
- assert.equal(settled.upgrade.last.stale, true, '版本未前进必须标 stale(镜像滞后/静默未升)')
398
- assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0)
399
- // stale 抑制自动重启:不调度退出、不标手动指引;重启互斥同步释放
400
- assert.equal(settled.autoRestartScheduled, false)
401
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
402
- assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
403
- const restart = await post(routes, '/api/maintain/restart')
404
- assert.equal(restart.status, 200, 'stale 落定后重启互斥必须已释放')
405
- })
406
-
407
- test('upgrade:stale 落定不调度自动重启(宿主退出零触发)', async () => {
408
- const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
409
- const exits = []
410
- const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
411
- apply(ctx)
412
- await post(routes, '/api/maintain/upgrade')
413
- for (let i = 0; i < 50; i += 1) {
414
- await new Promise((resolve) => setTimeout(resolve, 100))
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) break
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]*?scheduleAutoRestart\(\)/, '调度链必须置位标记并调用 scheduleAutoRestart')
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, undefined)
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
+ })
@@ -219,19 +219,29 @@ test('重试:假命令真实进程 文件锁失败后重试成功', async () =>
219
219
  }
220
220
  })
221
221
 
222
- test('自动重启:四条件守卫(成功/非 stale/非手动直跑/appExit 可用)', () => {
223
- // 成功+托管(含 unknown)→ 调度
224
- assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: true, requiresManualRestart: false })
225
- assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true }), { schedule: true, requiresManualRestart: false })
226
- assert.deepEqual(judgeAutoRestart({ ok: true, stale: false, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true }), { schedule: true, requiresManualRestart: false })
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, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true }), { schedule: false, requiresManualRestart: 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, stale: false, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true }), { schedule: false, requiresManualRestart: 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, stale: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false }), { schedule: false, requiresManualRestart: false })
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, stale: false, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false }), { schedule: false, requiresManualRestart: 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
  })