@mzzsfy/dsh-maintain 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,11 +6,11 @@ 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 不调度。
13
- - 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
11
+ - 升级结果校验:升级命令成功且宿主继续运行(手动直跑或本次未勾选自动重启)时复读磁盘版本对比——版本未前进(镜像滞后或静默失败)或未达通道目标版本时,面板与浮条标注 stale 提示;自动重启路径不做复读与 stale 判定(升级落定钩子禁止网络请求与磁盘读取),版本是否前进由重启后的启动检查呈现。运行版本已是通道最新时升级入口直接拒绝(409 防降级,面板按钮同步禁用),重装场景走命令行。超时强杀意味着安装可能只完成一半,重启宿主前先确认命令需否重跑。
12
+ - 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级确认弹窗勾选自动重启且命令成功(退出码 0)时,托管环境下延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启(弹窗内同步提示该环境约束);检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启。
13
+ - 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
14
14
  - 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
15
15
  - 面板设置编辑:升级命令、轮询间隔、镜像地址均可在面板直接编辑保存,与 settings.yaml 等效(轮询间隔保存即重排,镜像地址保存即重查);host 侧拒绝空命令、非 http(s) 地址与带查询串/锚点的镜像地址(拼接 dist-tags 路径时 query 会吞掉路径),面板清空输入即恢复对应默认值。
16
16
  - 操作反馈:检查更新、通道切换、面板保存与升级触发的失败经全站浮出通知(@mzzsfy/dsh-toast)提示;页面加载失败与重启超时等状态提示仍在面板内展示。
@@ -42,7 +42,7 @@ maintain:
42
42
  registryBase: https://registry.npmjs.org # 官方源不可达时可改为镜像地址
43
43
  ```
44
44
 
45
- 升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效 settings.yaml,热生效)。
45
+ 升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效 settings.yaml,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
46
46
 
47
47
  ## 重启须知
48
48
 
package/package.json CHANGED
@@ -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.1",
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)
@@ -152,6 +179,11 @@ async function api(url, options) {
152
179
  return payload
153
180
  }
154
181
 
182
+ // 状态预取:模块加载即页面加载时发起,首次进入面板不再与宿主的初始请求风暴
183
+ // (会话树全量扫描等单请求秒级负载)排队;预取只服务首次挂载,消费即置空,
184
+ // 后续挂载保持现拉,不因复用旧快照而退化
185
+ let statusPrefetch = api(STATUS_URL).catch(() => null)
186
+
155
187
  function post(url, body) {
156
188
  return api(url, { method: 'POST', body: body === undefined ? '{}' : JSON.stringify(body) })
157
189
  }
@@ -363,6 +395,21 @@ function EditRow(props) {
363
395
  )
364
396
  }
365
397
 
398
+ // 布尔开关:原生 checkbox 保可访问性,视觉为 track 胶囊 + thumb 圆点(规约形态);
399
+ // input 与 track 必须保持紧邻兄弟(状态选择器为紧邻组合器),文字标签排最后,视觉经 order 提前
400
+ function Switch(props) {
401
+ return h('label', { className: 'dm-switch', title: props.title },
402
+ h('input', {
403
+ type: 'checkbox',
404
+ checked: props.checked === true,
405
+ disabled: props.disabled === true,
406
+ onChange: (e) => props.onChange(e.target.checked),
407
+ }),
408
+ h('span', { className: 'dm-switch__track' }, h('span', { className: 'dm-switch__thumb' })),
409
+ props.label ? h('span', { className: 'dm-row__label' }, props.label) : null,
410
+ )
411
+ }
412
+
366
413
  // 版本区:当前版本、通道切换、通道最新版、结论、检查时间与错误、刷新与升级、升级命令编辑。
367
414
  function VersionCard(props) {
368
415
  const status = props.status
@@ -389,7 +436,7 @@ function VersionCard(props) {
389
436
  || status.verdict === VERDICT_UP_TO_DATE,
390
437
  title: status.verdict === VERDICT_UP_TO_DATE ? '当前已是通道最新版,无需升级;重装请走命令行' : undefined,
391
438
  onClick: props.onUpgrade,
392
- }, props.upgradeArmed ? '确认升级' : '升级'),
439
+ }, '升级'),
393
440
  ),
394
441
  status.restartPending === true
395
442
  ? h('div', { className: 'dm-notice dm-notice--warn' },
@@ -522,11 +569,51 @@ function OpsCard(props) {
522
569
  )
523
570
  }
524
571
 
572
+ // 升级确认弹窗:展示将执行的命令({tag} 已替换为追踪通道)与自动重启勾选,确认才触发。
573
+ // 自动重启是会话级选择(不持久化),勾/不勾的后果在窗内明示:
574
+ // 勾选=命令成功后宿主延迟 3 秒退出,由进程管理器拉起新版本,页面随宿主恢复自动刷新;
575
+ // 不勾选=宿主继续运行旧版本,面板提示"重启宿主后生效",需手动点重启按钮
576
+ function UpgradeDialog(props) {
577
+ // 每次打开都重新挂载(MaintainApp 条件渲染),默认勾选随挂载重置
578
+ const [autoRestart, setAutoRestart] = useState(true)
579
+ const status = props.status
580
+ const template = typeof status.upgradeTemplate === 'string' && status.upgradeTemplate.length > 0
581
+ ? status.upgradeTemplate
582
+ : DEFAULT_UPGRADE_TEMPLATE
583
+ const command = template.split('{tag}').join(status.channel)
584
+ return h('div', { className: 'dm-dialog__mask', onClick: props.onCancel },
585
+ h('div', { className: 'dm-dialog', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
586
+ h('div', { className: 'dm-card__title' }, '确认升级'),
587
+ h('div', { className: 'dm-dialog__hint' }, '将执行以下命令(升级期间页面可离开,进度由右下角浮条提示):'),
588
+ h('pre', { className: 'dm-pre' }, command),
589
+ h('div', { className: 'dm-dialog__section' },
590
+ h(Switch, {
591
+ label: '升级完成后自动重启宿主',
592
+ checked: autoRestart,
593
+ onChange: setAutoRestart,
594
+ }),
595
+ h('div', { className: 'dm-dialog__outcome' },
596
+ autoRestart
597
+ ? '已勾选:升级命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起新版本,本页随宿主恢复自动刷新。升级落定后宿主不再执行任何网络请求与磁盘读取。'
598
+ : '未勾选:升级完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到新版本。'),
599
+ status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
600
+ ? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
601
+ : null,
602
+ ),
603
+ h('div', { className: 'dm-row' },
604
+ h('span', { className: 'dm-spacer' }),
605
+ h('button', { className: 'dm-btn', onClick: props.onCancel }, '取消'),
606
+ h('button', { className: 'dm-btn', onClick: () => props.onConfirm(autoRestart) }, '开始升级'),
607
+ ),
608
+ ),
609
+ )
610
+ }
611
+
525
612
  function MaintainApp() {
526
613
  const [status, setStatus] = useState(null)
527
614
  const [error, setError] = useState(null)
528
615
  const [busy, setBusy] = useState({})
529
- const [upgradeArmed, setUpgradeArmed] = useState(false)
616
+ const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false)
530
617
  const [restartArmed, setRestartArmed] = useState(false)
531
618
  const [restarting, setRestarting] = useState(false)
532
619
  // 重启探测的跨拍状态快照 {lost,pid,bootAt,readyStreak}:lost 与 readyStreak 随拍更新,
@@ -541,7 +628,11 @@ function MaintainApp() {
541
628
  }
542
629
 
543
630
  function load() {
544
- return api(STATUS_URL)
631
+ // 首次挂载消费预取快照(缺失/失败回退现拉),此后每次挂载现拉
632
+ const primed = statusPrefetch
633
+ statusPrefetch = null
634
+ const base = primed !== null ? primed.then((snap) => (snap !== null ? snap : api(STATUS_URL))) : api(STATUS_URL)
635
+ return base
545
636
  .then((next) => { setStatus(next); setError(null); return next })
546
637
  .catch((loadError) => { setError('读取状态失败:' + (loadError && loadError.message ? loadError.message : String(loadError))); return null })
547
638
  }
@@ -742,15 +833,10 @@ function MaintainApp() {
742
833
  submitEdit(REGISTRY_BASE_URL, { base }, 'registryBase', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
743
834
  }
744
835
 
745
- function onUpgrade() {
746
- if (!upgradeArmed) {
747
- setUpgradeArmed(true)
748
- setRestartArmed(false)
749
- return
750
- }
751
- setUpgradeArmed(false)
836
+ function onUpgrade(autoRestart) {
837
+ setUpgradeDialogOpen(false)
752
838
  markBusy('upgrade', true)
753
- post(UPGRADE_URL)
839
+ post(UPGRADE_URL, { autoRestart: autoRestart === true })
754
840
  .then((next) => {
755
841
  setStatus(next); setError(null)
756
842
  // 升级转后台执行:观察器接管状态跟踪与完成提示,页面可离开
@@ -764,7 +850,6 @@ function MaintainApp() {
764
850
  if (restartPendingRef.current) return
765
851
  if (!restartArmed) {
766
852
  setRestartArmed(true)
767
- setUpgradeArmed(false)
768
853
  return
769
854
  }
770
855
  setRestartArmed(false)
@@ -826,13 +911,13 @@ function MaintainApp() {
826
911
  error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
827
912
  }
828
913
 
829
- // 升级入口不可用即解除两段式待发:渲染时派生,不引入 effect。
914
+ // 升级入口不可用即关闭确认弹窗:渲染时派生,不引入 effect。
830
915
  // 不可用与 VersionCard 按钮 disabled 条件同源(升级进行中/残留锁/已最新/重启中)
831
916
  const upgradeUnavailable = restarting
832
917
  || (status !== null && ((status.upgrade && status.upgrade.running === true)
833
918
  || status.upgradeLockHeld === true
834
919
  || status.verdict === VERDICT_UP_TO_DATE))
835
- const upgradeArmedLive = upgradeArmed && !upgradeUnavailable
920
+ const upgradeDialogOpenLive = upgradeDialogOpen && !upgradeUnavailable
836
921
  // 活跃工作计数:重启确认态显示计数并发 force 越过门控
837
922
  const activeWorkTotal = status !== null && status.activeWork ? status.activeWork.total : 0
838
923
 
@@ -850,13 +935,12 @@ function MaintainApp() {
850
935
  h(VersionCard, {
851
936
  status,
852
937
  busy,
853
- upgradeArmed: upgradeArmedLive,
854
938
  restartArmed,
855
939
  restarting,
856
940
  onRefresh,
857
941
  onChannel,
858
942
  onTemplateSave,
859
- onUpgrade,
943
+ onUpgrade: () => { setRestartArmed(false); setUpgradeDialogOpen(true) },
860
944
  onRestart,
861
945
  }),
862
946
  h(SettingsCard, {
@@ -868,6 +952,11 @@ function MaintainApp() {
868
952
  }),
869
953
  h(UpgradeCard, { status, restartArmed, restarting, activeWorkTotal, onRestart }),
870
954
  h(OpsCard, { status, armed: restartArmed, restarting, activeWorkTotal, onRestart }),
955
+ upgradeDialogOpenLive ? h(UpgradeDialog, {
956
+ status,
957
+ onCancel: () => setUpgradeDialogOpen(false),
958
+ onConfirm: onUpgrade,
959
+ }) : null,
871
960
  )
872
961
  }
873
962
 
package/src/core.mjs CHANGED
@@ -241,6 +241,15 @@ export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs
241
241
  return tags
242
242
  }
243
243
 
244
+ // 运行版本事实源:运行进程 argv[1] 指向运行宿主自己的 bin.js(<包根>/lib/bin.js),
245
+ // dirname 后上一级即包根,其 package.json 就是运行宿主清单。多副本共存时全局布局
246
+ // 候选与 createRequire 解析都会命中其他副本(全局 0.1.5 + 副本跑旧版实测),只有
247
+ // argv[1] 与运行进程绑定,列为最高优先级候选。
248
+ export function runningHostCandidate({ argv1, pathImpl = win32 }) {
249
+ if (!argv1 || typeof argv1 !== 'string') return null
250
+ return pathImpl.join(pathImpl.dirname(argv1), '..', 'package.json')
251
+ }
252
+
244
253
  // npm 全局布局下宿主包清单位置的候选序列;win 与 posix 目录结构不同,按声明平台选实现,与宿主 OS 解耦。
245
254
  export function hostPackageCandidates({ execPath, platform }) {
246
255
  const pathImpl = platform === 'win32' ? win32 : posix
@@ -251,17 +260,20 @@ export function hostPackageCandidates({ execPath, platform }) {
251
260
  return [pathImpl.join(globalDir, '@deepseek-ai', 'dsh', 'package.json')]
252
261
  }
253
262
 
254
- // 宿主实际安装版本:先按全局布局候选读文件,再退 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 提示文案对拍
@@ -74,8 +74,8 @@ const audit = (endpoint, outcome, extra) => {
74
74
 
75
75
  // 活跃工作检测:agents/jobs/terminals 全软依赖方法面守卫,缺失或异常即降级放行(fail-open,
76
76
  // 不因安全网缺失死锁重启)。terminals 主路径逐 agent 的 realm 作用域解析(PTY 注册表随
77
- // agent 挂载且 isolate,共享根作用域因 realm 隔离恒缺,仅兜底);sessions 不作活跃指标;
78
- // 禁止顶层 inject(阻塞装载)。导出仅供测试
77
+ // agent 挂载且 isolate,共享根作用域因 realm 隔离恒缺,仅兜底;无 agent 时服务面
78
+ // 不可见属常态,不计降级);sessions 不作活跃指标;禁止顶层 inject(阻塞装载)。导出仅供测试
79
79
  // 降级留痕按服务键进程首见一次:status 每拍重检,不限频会刷屏
80
80
  const degradedWarned = new Set()
81
81
 
@@ -145,7 +145,9 @@ export function collectActiveWork(ctx) {
145
145
  } catch (error) {
146
146
  degrade('terminals', '根探测异常: ' + (error && error.message ? error.message : String(error)))
147
147
  }
148
- if (!sawTerminalsService) degrade('terminals', 'terminals 服务面不可用')
148
+ // 服务面随 agent realm 挂载:无 agent 时不可见属常态(无对象即无终端),不计降级;
149
+ // 有 agent 而探不到服务面(可探性缺失或面缺失)属异常,降级留痕
150
+ if (!sawTerminalsService && agentList.length > 0) degrade('terminals', 'terminals 服务面不可用')
149
151
  } catch (error) {
150
152
  degrade('terminals', error && error.message ? error.message : String(error))
151
153
  }
@@ -202,12 +204,14 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
202
204
  return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
203
205
  }
204
206
 
205
- // 升级后自动重启守卫:四条件缺一不可——成功、非 stale、非手动直跑、appExit 可用。
206
- // 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引
207
- export function judgeAutoRestart({ ok, stale, runtimeKind, hasExit }) {
207
+ // 升级后自动重启守卫:四条件缺一不可——成功、非手动直跑、本次勾选 enabled、appExit 可用。
208
+ // 手动直跑(双 TTY)进程退出后无人拉起,只标 requiresManualRestart 交面板指引,
209
+ // 该指引是环境约束事实,不受勾选影响;enabled 由升级路由严格 boolean 校验保证
210
+ // 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
211
+ export function judgeAutoRestart({ ok, runtimeKind, hasExit, enabled }) {
208
212
  if (ok !== true) return { schedule: false, requiresManualRestart: false }
209
- if (stale === true) return { schedule: false, requiresManualRestart: false }
210
213
  if (runtimeKind === RUNTIME_KINDS.MANUAL_START) return { schedule: false, requiresManualRestart: true }
214
+ if (enabled !== true) return { schedule: false, requiresManualRestart: false }
211
215
  if (hasExit !== true) return { schedule: false, requiresManualRestart: false }
212
216
  return { schedule: true, requiresManualRestart: false }
213
217
  }
@@ -361,6 +365,7 @@ export function apply(ctx) {
361
365
  return resolveHostVersion({
362
366
  execPath: process.execPath,
363
367
  platform: process.platform,
368
+ argv1: process.argv[1],
364
369
  readFileImpl: readFile,
365
370
  resolveImpl: undefined,
366
371
  })
@@ -434,7 +439,8 @@ export function apply(ctx) {
434
439
  return
435
440
  }
436
441
  const dispose = timerCtx.interval(() => {
437
- if (checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
442
+ // restartScheduled 窗口内不再发起检查:关机延迟期间的网络请求与磁盘读取都是半写状态风险
443
+ if (restartScheduled || checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
438
444
  runCheck().then(scheduleNext, scheduleNext)
439
445
  }, TICK_MS)
440
446
  ctx.effect(() => dispose, 'dsh-maintain poll interval')
@@ -480,7 +486,7 @@ export function apply(ctx) {
480
486
  }
481
487
  }
482
488
 
483
- function triggerUpgrade() {
489
+ function triggerUpgrade(autoRestart) {
484
490
  const config = readSettings(ctx)
485
491
  // 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
486
492
  const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
@@ -505,8 +511,8 @@ export function apply(ctx) {
505
511
  // running 即串行化门闩:路由检查与本处置位之间无 await,单线程下无竞态窗口
506
512
  upgrade = { running: true, last }
507
513
  writeUpgradeLock(last.startedAt)
508
- audit('upgrade', 'triggered', 'command=' + singleLine(command))
509
- void performUpgrade(command, last, config.channel)
514
+ audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
515
+ void performUpgrade(command, last, config.channel, autoRestart === true)
510
516
  }
511
517
 
512
518
  // 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
@@ -517,20 +523,24 @@ export function apply(ctx) {
517
523
  } catch { /* 锁不可写仅损失跨进程防护,内存门闩仍生效 */ }
518
524
  }
519
525
 
520
- // 自动重启与手动重启共用 restartScheduled 互斥:调度窗口内到达的升级被 409,
521
- // 手动重启端点幂等;autoRestartScheduled 单独给 status/client 分流终态文案
522
- function scheduleAutoRestart() {
526
+ // 宿主退出调度唯一入口:手动重启(HTTP 入口)与升级落定(内部入口)共用,
527
+ // 置双向互斥标记、留审计、延迟 exit(0)。调度窗口内升级被 409,手动重启幂等
528
+ function scheduleHostExit({ detail, autoRestart = false, delayMs }) {
523
529
  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)
530
+ if (autoRestart === true) autoRestartScheduled = true
531
+ audit('restart', autoRestart === true ? 'scheduled' : 'triggered', detail + ' delayMs=' + delayMs)
532
+ // 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
533
+ // 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
534
+ setTimeout(() => exit(0), delayMs)
527
535
  }
528
536
 
529
- async function performUpgrade(command, last, channel) {
537
+ async function performUpgrade(command, last, channel, autoRestart) {
538
+ // settle 声明在 try 外:继续运行路径的版本复读在 finally 中消费它
539
+ let settle = null
530
540
  try {
531
- // 触发前快照磁盘版本,成功落定后复读对比,版本未前进或未达目标即标 stale
541
+ // 触发前快照磁盘版本,继续运行路径复读时以此为对比基线
532
542
  last.previousVersion = await resolveCurrentHostVersion()
533
- const settle = await runUpgradeWithRetry({
543
+ settle = await runUpgradeWithRetry({
534
544
  command,
535
545
  onAttemptStart: () => writeUpgradeLock(Date.now()),
536
546
  })
@@ -547,20 +557,11 @@ export function apply(ctx) {
547
557
  stdoutTail: settle.stdoutTail,
548
558
  stderrTail: settle.stderrTail,
549
559
  })
550
- if (settle.ok) {
551
- last.installedVersion = await resolveCurrentHostVersion()
552
- const tags = snapshot.tags
553
- const freshness = judgeUpgradeFreshness({
554
- previousVersion: last.previousVersion,
555
- installedVersion: last.installedVersion,
556
- channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
557
- })
558
- last.stale = freshness.stale
559
- last.reason = freshness.reason
560
- }
561
560
  } catch (error) {
562
561
  last.error = error && error.message ? error.message : String(error)
563
562
  } finally {
563
+ // 关机路径经 return 提前离场会吞掉 finally 内新抛异常:本块各消费点已 try/catch
564
+ // 防御(删锁/复读/runCheck),后续修改必须保持该契约,勿在此块新增可抛调用
564
565
  if (last.finishedAt === null) last.finishedAt = Date.now()
565
566
  upgrade = { running: false, last }
566
567
  // stillRunning(强杀后进程树疑似仍在写全局目录)时保留锁文件,由过期机制收敛,
@@ -580,14 +581,33 @@ export function apply(ctx) {
580
581
  + (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
581
582
  audit('upgrade', last.ok === true ? 'ok' : 'failed',
582
583
  'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
583
- // 自动重启守卫:等环境检测落定后按四条件分流(手动直跑只标指引,不退出)
584
+ // 关机前置判定只取内存输入(启动期缓存的运行环境 + 本次会话勾选):升级命令刚
585
+ // 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
584
586
  await runtimeEnvReady
585
- const decision = judgeAutoRestart({ ok: last.ok === true, stale: last.stale === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function' })
587
+ const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
586
588
  if (decision.requiresManualRestart === true) last.requiresManualRestart = true
587
589
  if (decision.schedule === true) {
590
+ // 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
588
591
  // last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
589
592
  last.autoRestartScheduled = true
590
- scheduleAutoRestart()
593
+ scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
594
+ return
595
+ }
596
+ // 继续运行路径(失败/手动直跑/本次未勾选自动重启):复读磁盘版本标 stale,版本未前进提示保留
597
+ if (settle !== null && settle.ok) {
598
+ // 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
599
+ // 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
600
+ try {
601
+ last.installedVersion = await resolveCurrentHostVersion()
602
+ const tags = snapshot.tags
603
+ const freshness = judgeUpgradeFreshness({
604
+ previousVersion: last.previousVersion,
605
+ installedVersion: last.installedVersion,
606
+ channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
607
+ })
608
+ last.stale = freshness.stale
609
+ last.reason = freshness.reason
610
+ } catch { /* 复读失败仅损失 stale 提示,不覆盖升级成功结果 */ }
591
611
  }
592
612
  // 升级结束后自动重新检查版本并重排轮询(命令可能改了本地版本)
593
613
  runCheck().then(scheduleNext, scheduleNext)
@@ -606,6 +626,10 @@ export function apply(ctx) {
606
626
  {
607
627
  path: API_PATHS.REFRESH,
608
628
  handler: route('POST', WRITE, async (req, res) => {
629
+ if (restartScheduled) {
630
+ sendJson(res, 409, { error: '重启已调度,禁止检查更新' })
631
+ return
632
+ }
609
633
  await drainInFlightThenCheck()
610
634
  scheduleNext()
611
635
  sendJson(res, 200, await currentStatus())
@@ -698,6 +722,11 @@ export function apply(ctx) {
698
722
  return
699
723
  }
700
724
  await settings.update(NAMESPACE, { registryBase: base })
725
+ if (restartScheduled) {
726
+ // 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
727
+ sendJson(res, 200, await currentStatus())
728
+ return
729
+ }
701
730
  await drainInFlightThenCheck()
702
731
  scheduleNext()
703
732
  sendJson(res, 200, await currentStatus())
@@ -706,6 +735,15 @@ export function apply(ctx) {
706
735
  {
707
736
  path: API_PATHS.UPGRADE,
708
737
  handler: route('POST', WRITE, async (req, res) => {
738
+ // body 校验必须在全部门控之前:门控到 triggerUpgrade 置位之间不得插入 await,
739
+ // 否则并发请求双双通过 409 门控,两个升级进程并发写全局目录
740
+ // 自动重启为确认弹窗的会话级勾选,严格 boolean:缺失/宽转一律拒绝,防静默翻转关机行为
741
+ const rawBody = await readBody(req)
742
+ const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
743
+ if (typeof requestBody.autoRestart !== 'boolean') {
744
+ sendJson(res, 400, { error: 'autoRestart 必须是 boolean' })
745
+ return
746
+ }
709
747
  // 与重启调度双向互斥:restartScheduled 置位到 exit(0) 执行的窗口内触发升级,
710
748
  // detached 孤儿会与新宿主交错
711
749
  if (upgrade.running || restartScheduled) {
@@ -737,13 +775,18 @@ export function apply(ctx) {
737
775
  })
738
776
  return
739
777
  }
740
- triggerUpgrade()
778
+ triggerUpgrade(requestBody.autoRestart)
741
779
  sendJson(res, 200, await currentStatus())
742
780
  }),
743
781
  },
744
782
  {
745
783
  path: API_PATHS.RESTART,
746
784
  handler: route('POST', WRITE, async (req, res) => {
785
+ // body 读取必须在全部门控之前:幂等检查到 scheduleHostExit 置位之间不得插入
786
+ // await,否则并发请求双双越过幂等门,双重调度 exit
787
+ const rawBody = await readBody(req)
788
+ // 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
789
+ const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
747
790
  // 与升级门闩互斥:升级子进程经 detached+unref 存活于宿主死后,
748
791
  // 重启后新宿主门闩归零会放行第二次升级,双 npm install 并发写全局目录
749
792
  if (upgrade.running) {
@@ -762,9 +805,6 @@ export function apply(ctx) {
762
805
  }
763
806
  // 活跃工作门控:重启可被 force 越过(升级后自动重启链路无法等待人工确认,
764
807
  // 对齐 dsh-service:升级不可越/重启 force 可越)
765
- const rawBody = await readBody(req)
766
- // 'null' 字面量解析为 null:按空体处理,防 null.force 形态的内部错误外泄
767
- const requestBody = rawBody ? (JSON.parse(rawBody) || {}) : {}
768
808
  const activeWork = collectActiveWork(ctx)
769
809
  if (activeWork.total > 0 && requestBody.force !== true) {
770
810
  audit('restart', 'rejected', 'reason=active-work total=' + activeWork.total)
@@ -775,12 +815,9 @@ export function apply(ctx) {
775
815
  })
776
816
  return
777
817
  }
778
- restartScheduled = true
779
- audit('restart', 'triggered', requestBody.force === true ? 'forced=true' : 'forced=false')
818
+ // 响应先发,退出调度经统一入口(与升级落定关机同一条链)
780
819
  sendJson(res, 200, { ok: true, restarting: true })
781
- // 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
782
- // 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
783
- setTimeout(() => exit(0), RESTART_DELAY_MS)
820
+ scheduleHostExit({ detail: 'forced=' + (requestBody.force === true), delayMs: RESTART_DELAY_MS })
784
821
  }),
785
822
  },
786
823
  ]
@@ -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 })
@@ -589,6 +740,33 @@ test('collectActiveWork:服务缺失或异常 fail-open 降级', () => {
589
740
  assert.equal(realmOnly.detectionAvailable, true)
590
741
  })
591
742
 
743
+ test('collectActiveWork:无 agent 时 terminals 服务面不可见属常态,不降级', () => {
744
+ const warns = []
745
+ const originalWarn = console.warn
746
+ console.warn = (text) => warns.push(String(text))
747
+ try {
748
+ const agents = { list: () => [] }
749
+ const jobs = { list: () => [] }
750
+ const result = collectActiveWork({ get: (n) => (n === 'agents' ? agents : n === 'jobs' ? jobs : undefined) })
751
+ assert.deepEqual([result.agents, result.jobs, result.terminals], [0, 0, 0])
752
+ assert.equal(result.detectionAvailable, true)
753
+ // 告警断言受模块级去重影响恒真,防线是上一行 detectionAvailable 断言
754
+ assert.equal(warns.some((text) => text.includes('活跃工作检测降级')), false, '常态空窗不得打降级告警')
755
+ } finally {
756
+ console.warn = originalWarn
757
+ }
758
+ })
759
+
760
+ test('collectActiveWork:有 agent 而服务面缺失(可探或不可探),仍降级', () => {
761
+ const probeless = { id: 'a1', status: 'idle' }
762
+ const blindProbe = { id: 'a2', status: 'idle', ctx: { get: () => undefined } }
763
+ const probelessResult = collectActiveWork({ get: (n) => (n === 'agents' ? { list: () => [probeless] } : n === 'jobs' ? { list: () => [] } : undefined) })
764
+ assert.equal(probelessResult.detectionAvailable, false)
765
+ const blindResult = collectActiveWork({ get: (n) => (n === 'agents' ? { list: () => [blindProbe] } : n === 'jobs' ? { list: () => [] } : undefined) })
766
+ assert.equal(blindResult.detectionAvailable, false)
767
+ assert.equal(blindResult.terminals, 0)
768
+ })
769
+
592
770
  test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
593
771
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
594
772
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
@@ -596,7 +774,7 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
596
774
  const terminals = { list: () => [] }
597
775
  const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
598
776
  apply(ctx)
599
- const denied = await post(routes, '/api/maintain/upgrade')
777
+ const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
600
778
  assert.equal(denied.status, 409)
601
779
  assert.match(denied.payload.error, /活跃工作/)
602
780
  assert.equal(denied.payload.items.agents, 1)
@@ -649,7 +827,7 @@ test('自动重启接线:落定链消费运行环境并分流调度与手动指
649
827
  assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
650
828
  assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
651
829
  assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
652
- assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleAutoRestart\(\)/, '调度链必须置位标记并调用 scheduleAutoRestart')
830
+ assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
653
831
  })
654
832
 
655
833
  test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
@@ -659,7 +837,7 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
659
837
  const exits = []
660
838
  const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
661
839
  apply(ctx)
662
- await post(routes, '/api/maintain/upgrade')
840
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
663
841
  let settled = null
664
842
  for (let i = 0; i < 50 && settled === null; i += 1) {
665
843
  await new Promise((resolve) => setTimeout(resolve, 100))
@@ -670,8 +848,8 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
670
848
  assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
671
849
  assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
672
850
  assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
673
- // stale 优先:版本未前进时不引导手动重启(重启无意义)
674
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
851
+ // 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
852
+ assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
675
853
  } finally {
676
854
  delete process.env.DSH_MAINTAIN_RUNTIME_ENV
677
855
  }
@@ -687,7 +865,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
687
865
  const agents = { list: () => [{ id: 'a1', status: 'running' }] }
688
866
  const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
689
867
  apply(ctx)
690
- await post(routes, '/api/maintain/upgrade')
868
+ await post(routes, '/api/maintain/upgrade', { autoRestart: true })
691
869
  assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
692
870
  await post(routes, '/api/maintain/restart')
693
871
  assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
@@ -702,7 +880,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
702
880
  console.warn = (text) => plainWarns.push(String(text))
703
881
  try {
704
882
  apply(plainCtx.ctx)
705
- await post(plainCtx.routes, '/api/maintain/upgrade')
883
+ await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
706
884
  for (let i = 0; i < 50; i += 1) {
707
885
  await new Promise((resolve) => setTimeout(resolve, 100))
708
886
  const status = await get(plainCtx.routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
@@ -0,0 +1,45 @@
1
+ // 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
2
+ // 属无报错的 UI 损坏,故对源文本做静态断言锁死结构(usage-panel 同款守卫)
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
11
+
12
+ test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
13
+ assert.match(source, /\.dm-switch input\[type="checkbox"\] \{ position:absolute/)
14
+ })
15
+
16
+ test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
17
+ const bare = source.match(/\.dm-switch input:(?!\[type)[a-z-]+/g)
18
+ assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
19
+ })
20
+
21
+ test('switch 文字标签的视觉提前依赖 order 规则,删除即回退到 track 右侧', () => {
22
+ assert.match(source, /\.dm-switch \.dm-row__label \{ order:-1/)
23
+ })
24
+
25
+ test('checkbox 仅允许出现在 Switch 组件内,禁止裸 checkbox 直出', () => {
26
+ const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
27
+ assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
28
+ const factoryStart = source.indexOf('function Switch(')
29
+ const factoryEnd = source.indexOf('dm-switch__thumb', factoryStart)
30
+ assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 Switch 组件内')
31
+ })
32
+
33
+ test('Switch 产出顺序为 input 在前 track 在后', () => {
34
+ const factory = source.slice(source.indexOf('function Switch('))
35
+ assert.ok(factory.indexOf("h('input'") < factory.indexOf('dm-switch__track'), '组件内 input 必须先于 track')
36
+ })
37
+
38
+ test('Switch 的 input 与 track 必须紧邻(状态选择器为紧邻组合器,中间插元素即视觉失效)', () => {
39
+ const factory = source.slice(source.indexOf('function Switch('))
40
+ const inputAt = factory.indexOf("h('input'")
41
+ const trackAt = factory.indexOf("h('span', { className: 'dm-switch__track'")
42
+ assert.ok(inputAt >= 0 && trackAt > inputAt, '前置:input 先于 track')
43
+ const between = factory.slice(inputAt + 1, trackAt)
44
+ assert.equal((between.match(/\bh\('/g) || []).length, 0, 'input 与 track 之间不得插入其他元素')
45
+ })
@@ -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
  })